From b7657fe92ba404351806115298c7d672e86360b1 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:49:06 +0000 Subject: [PATCH 01/26] fix: refuse duplicate ids and unknown src in exchanged timelines A hand-edited or exchanged timeline.jsonl could carry two utterances sharing one id: analyze's id-keyed uttText index silently kept only the later one, so an honest verbatim quote of the first was rejected while a quote of the second validated for a finding anchored at the first's time, durably pairing a quote with a moment it was never spoken at. Likewise an entry whose src was neither speech nor event fell out of both of report's render buckets and vanished from the rendered timeline while end() still counted its time into the Duration header and analyze kept its id citable. analyze now refuses duplicate non-empty ids (in SafeText form, the form the index and emitted request use) at timeline load, naming both lines; report and analyze refuse an unknown src via a shared timeline.CheckSrc, naming the line and value. report deliberately keeps rendering duplicate ids (its join is positional); merge-produced timelines are unaffected. Assisted-by: Claude:claude-fable-5 --- internal/analyze/analyze_test.go | 46 ++++++++++++++++++++++++++++++++ internal/analyze/ingest.go | 29 +++++++++++++++++++- internal/report/report.go | 10 +++++++ internal/report/report_test.go | 27 +++++++++++++++++++ internal/timeline/timeline.go | 23 ++++++++++++++++ 5 files changed, 134 insertions(+), 1 deletion(-) diff --git a/internal/analyze/analyze_test.go b/internal/analyze/analyze_test.go index e69bd81..275652e 100644 --- a/internal/analyze/analyze_test.go +++ b/internal/analyze/analyze_test.go @@ -766,3 +766,49 @@ func TestEmitRequestSanitisesTimelineBidi(t *testing.T) { t.Fatalf("sanitised timeline text missing from the emitted request:\n%s", got) } } + +// TestIngestRefusesDuplicateTimelineIDs pins the duplicate-id refusal in +// loadTimeline. Before it, indexTimeline's id-keyed uttText map silently kept +// only the LAST utterance sharing an id, so against this timeline a quote +// spoken in the second utt-001 validated for a finding anchored at the first +// one's time — the exact quote-to-moment fabrication the verbatim gate exists +// to prevent — while an honest quote of the first utterance was rejected. +func TestIngestRefusesDuplicateTimelineIDs(t *testing.T) { + const dupTimeline = `{"t":1,"src":"speech","id":"utt-001","payload":{"speaker":"P1","t1":2,"text":"first thing"}} +{"t":19.2,"src":"event","id":"ev-003","payload":{"kind":"click","route":"#general","selector":"[data-testid=save-btn]","text":"Save"}} +{"t":40,"src":"speech","id":"utt-001","payload":{"speaker":"P1","t1":45,"text":"second thing entirely"}} +` + const answer = `{"findings":[ + {"id":"F-001","t":1.0,"type":"bug","severity":3,"quote":"second thing entirely", + "evidence":["utt-001","ev-003"]} +]}` + dir := writeSession(t, dupTimeline) + _, err := Ingest(dir, strings.NewReader(answer)) + if err == nil { + t.Fatal("Ingest accepted a timeline with duplicate utterance ids") + } + if !strings.Contains(err.Error(), "utt-001") { + t.Fatalf("error must name the duplicated id: %v", err) + } + if _, statErr := os.Stat(filepath.Join(dir, session.FindingsFile)); statErr == nil { + t.Fatal("findings.jsonl written despite the refusal") + } +} + +// TestIngestRefusesUnknownTimelineSrc pins the CheckSrc gate on analyze's +// read path: an entry with an unrecognised src previously still contributed +// its id to the citable evidence set and its time to the session end bound, +// although report would never render it. +func TestIngestRefusesUnknownTimelineSrc(t *testing.T) { + const badSrc = `{"t":22,"src":"speech","id":"utt-004","payload":{"speaker":"P1","t1":28,"text":"I clicked save and nothing happened"}} +{"t":100,"src":"Event","id":"ev-009","payload":{"kind":"click","selector":"[data-testid=save-btn]"}} +` + dir := writeSession(t, badSrc) + _, err := Ingest(dir, strings.NewReader(goodAnswer)) + if err == nil { + t.Fatal("Ingest accepted a timeline entry with unknown src") + } + if !strings.Contains(err.Error(), "unknown src") || !strings.Contains(err.Error(), "line 2") { + t.Fatalf("error must name the unknown src and its line: %v", err) + } +} diff --git a/internal/analyze/ingest.go b/internal/analyze/ingest.go index cbe55db..f1a2573 100644 --- a/internal/analyze/ingest.go +++ b/internal/analyze/ingest.go @@ -21,12 +21,39 @@ import ( const maxAnswerBytes = 16 << 20 // loadTimeline reads the merged timeline, hinting to run merge first when it is -// missing (matching report). +// missing (matching report). It refuses a timeline this package cannot validate +// findings against: an unknown src (timeline.CheckSrc) or a duplicate entry id. +// The duplicate check exists because analyze is the one id-keyed consumer left: +// indexTimeline keys uttText by id, so of two utterances sharing an id only the +// later survives the index — an honest verbatim quote of the first is rejected, +// while a quote of the second validates for a finding anchored at the first's +// time, durably pairing a quote with a moment it was never spoken at. Merge +// never emits duplicates (transcribe and BuildEntries synthesise sequential +// ids), so this bites solely on a hand-edited or exchanged timeline.jsonl — +// report deliberately stays positional and keeps rendering such a file. Ids are +// compared in their session.SafeText form, the form the index and the emitted +// request use, so two ids distinct only by stripped bytes count as duplicates +// too. Entries with an empty id are skipped: they cannot be cited, and calling +// two id-less lines "duplicates of \"\"" would misname the actual problem. func loadTimeline(dir string) ([]timeline.Entry, error) { entries, err := session.ReadJSONL[timeline.Entry](filepath.Join(dir, session.TimelineFile)) if err != nil { return nil, fmt.Errorf("read timeline (run `testimony merge` first?): %w", err) } + if err := timeline.CheckSrc(entries); err != nil { + return nil, err + } + seen := map[string]int{} + for i, e := range entries { + id := session.SafeText(e.ID) + if id == "" { + continue + } + if prev, dup := seen[id]; dup { + return nil, fmt.Errorf("timeline id %q appears at lines %d and %d: ids must be unique for evidence to cite them unambiguously", id, prev, i+1) + } + seen[id] = i + 1 + } return entries, nil } diff --git a/internal/report/report.go b/internal/report/report.go index c9b2f73..3064bcd 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -36,6 +36,16 @@ func Render(dir string, window float64) (string, error) { if err != nil { return "", fmt.Errorf("read timeline (run `testimony merge` first?): %w", err) } + // Refuse an entry this renderer cannot place. The src switch below buckets + // into speech and events with no default, so an unknown src (reachable only + // from a hand-edited or exchanged timeline) previously vanished from the + // rendered timeline while end() still counted its time into the Duration + // header — the report asserted a session span its own timeline never + // reached, and the entry's id stayed citable by findings. Checked before the + // sort so the named line matches the file. + if err := timeline.CheckSrc(entries); err != nil { + return "", err + } // Order the record by time rather than trusting the file's line order. The // timeline this reads is not necessarily the one merge wrote: a hand-edited or // exchanged timeline.jsonl reaches Render directly — the same assumption the diff --git a/internal/report/report_test.go b/internal/report/report_test.go index 3a22548..1fa2498 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -570,3 +570,30 @@ func TestRenderSortsByTime(t *testing.T) { prev = at } } + +// TestRenderRefusesUnknownSrc pins the CheckSrc gate on report's read path. +// Before it, an entry whose src was neither "speech" nor "event" (reachable +// only from a hand-edited or exchanged timeline.jsonl) fell out of both render +// buckets and vanished from the rendered timeline, while end() still counted +// its time — the report claimed a Duration its own timeline never reached, at +// exit 0, silently omitting the entry from the human evidence artefact. +func TestRenderRefusesUnknownSrc(t *testing.T) { + dir := t.TempDir() + if err := session.SaveManifest(dir, session.Manifest{Session: "fixture"}); err != nil { + t.Fatalf("SaveManifest: %v", err) + } + const tl = `{"t":10,"src":"speech","id":"utt-001","payload":{"t1":12,"speaker":"P1","text":"hello"}} +{"t":11,"src":"event","id":"ev-002","payload":{"kind":"scroll"}} +{"t":100,"src":"Event","id":"ev-009","payload":{"kind":"click","selector":"[data-testid=save-btn]"}} +` + if err := os.WriteFile(filepath.Join(dir, session.TimelineFile), []byte(tl), 0o644); err != nil { + t.Fatalf("write timeline: %v", err) + } + _, err := Render(dir, 2.5) + if err == nil { + t.Fatal("Render silently accepted a timeline entry with unknown src") + } + if !strings.Contains(err.Error(), "unknown src") || !strings.Contains(err.Error(), "line 3") { + t.Fatalf("error must name the unknown src and its line: %v", err) + } +} diff --git a/internal/timeline/timeline.go b/internal/timeline/timeline.go index 7831e81..467178d 100644 --- a/internal/timeline/timeline.go +++ b/internal/timeline/timeline.go @@ -132,6 +132,29 @@ func BuildEntries(t0EpochMS int64, utts []Utterance, ints []Interaction) []Entry return entries } +// CheckSrc refuses a timeline whose entries carry a src outside the +// documented "speech" | "event" set. Merge only ever writes those two, so +// this bites solely on a hand-edited or exchanged timeline.jsonl — where an +// unknown src previously vanished without a word: report bucketed entries by +// src with no default case, so the entry was absent from the rendered +// timeline while end() still counted its time into the Duration header, and +// analyze still indexed its id as citable evidence. Silently omitting an +// entry from the human evidence artefact is the outcome the report package's +// own +Inf-sentinel fix rules out, so an unrenderable entry refuses loudly, +// naming its 1-based line, rather than reshaping the record. The src value is +// routed through session.SafeText because it is attacker-authorable and the +// message reaches a terminal. +func CheckSrc(entries []Entry) error { + for i, e := range entries { + switch e.Src { + case "speech", "event": + default: + return fmt.Errorf("timeline line %d: unknown src %q (want \"speech\" or \"event\")", i+1, session.SafeText(e.Src)) + } + } + return nil +} + // SpeechEnd returns the end time of a speech entry (its t1), falling back to // its start time. func SpeechEnd(e Entry) float64 { From e40371b62cf41142a7709e72704708a1f6e6ac0b Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:49:38 +0000 Subject: [PATCH 02/26] fix: match only device-open failures as permissions signatures ffmpeg prints its avfoundation input banner on every successful open (no -hide_banner is passed), so the bare module name in avSignatures made looksLikeAVFailure true for essentially every start-up failure: a full disk, a missing encoder, or an unwritable session directory was headlined 'most likely a permissions issue' with a System Settings pointer, and the non-permissions branch of classifyRecorderExit was unreachable for any failure past the input dump. The bare 'failed to' and 'abort' substrings matched benign warnings the same way. The list now carries device-open indicators only; the real TCC lines (Failed to create AVCaptureDeviceInput, Failed to open device, not authorized) keep matching. Assisted-by: Claude:claude-fable-5 --- internal/record/record_test.go | 34 ++++++++++++++++++++++++++++++++++ internal/record/tcc.go | 18 +++++++++++++++--- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/internal/record/record_test.go b/internal/record/record_test.go index ad0a8b5..a3475cd 100644 --- a/internal/record/record_test.go +++ b/internal/record/record_test.go @@ -1170,3 +1170,37 @@ func TestAnyExitReportsDeadChild(t *testing.T) { // Clean up the still-live child so no goroutine lingers. stopChild(live, time.Second) } + +// TestClassifyRecorderExitBannerIsNotAPermissionsSignature pins the signature +// list to device-open failures only. ffmpeg prints its avfoundation input +// banner on every SUCCESSFUL open, so with the bare module name in the list a +// start-up failure whose real cause followed the banner — a full disk, a +// missing encoder, an unwritable session directory — was headlined "most +// likely a permissions issue" with a System Settings pointer, misdirecting +// the operator to grant a permission they already hold. +func TestClassifyRecorderExitBannerIsNotAPermissionsSignature(t *testing.T) { + tails := map[string]string{ + "full disk": "Input #0, avfoundation, from ':default':\n Duration: N/A, start: 761698.132517, bitrate: 1536 kb/s\nav_interleaved_write_frame(): No space left on device", + "missing encoder": "Input #0, avfoundation, from '1:none':\nUnknown encoder 'libx264'", + "unwritable dir": "Input #0, avfoundation, from ':default':\nsessions/2026-07-29/audio.wav: Permission denied", + } + for name, tail := range tails { + if looksLikeAVFailure(tail) { + t.Fatalf("%s: the avfoundation input banner alone must not read as a device-open failure: %q", name, tail) + } + msg := classifyRecorderExit(streamMicrophone, errors.New("exit status 1"), tail, true) + if strings.Contains(msg, "permissions") || strings.Contains(msg, "Privacy & Security") { + t.Fatalf("%s: must not claim permissions: %q", name, msg) + } + } + // The genuine TCC denial lines keep matching. + for _, tail := range []string{ + "[AVFoundation indev @ 0x14f604580] Failed to create AVCaptureDeviceInput: -11852", + "[AVFoundation indev @ 0x0] Failed to open device.", + "[avfoundation @ 0x0] audio device not authorized to capture (status 0)", + } { + if !looksLikeAVFailure(tail) { + t.Fatalf("a real device-open failure must keep matching: %q", tail) + } + } +} diff --git a/internal/record/tcc.go b/internal/record/tcc.go index 7c5788b..f913c8b 100644 --- a/internal/record/tcc.go +++ b/internal/record/tcc.go @@ -9,12 +9,24 @@ import ( // cannot be opened. Their presence makes a permissions denial the most likely // cause — but because a busy or absent device fails the same way, the message // is phrased as "most likely", never asserted. +// +// Each signature must indicate a device-open failure on its own. The list once +// held the bare module name "avfoundation" and the bare "failed to" — but +// ffmpeg prints "Input #0, avfoundation, from ':default':" on every SUCCESSFUL +// input open (no -hide_banner is passed), so the module name sits in the +// stderr tail of essentially every start-up failure, whatever its cause. That +// made looksLikeAVFailure true unconditionally: a full disk, a missing +// encoder, or an unwritable session directory was headlined "most likely a +// permissions issue" with a System Settings pointer, and the non-permissions +// branch of classifyRecorderExit was unreachable for any failure past the +// input dump. The real macOS denial lines stay matched: TCC prints +// "[AVFoundation indev @ …] Failed to create AVCaptureDeviceInput" / +// "Failed to open device" and "not authorized to capture video". var avSignatures = []string{ - "avfoundation", "input/output error", "not authorized", - "failed to", - "abort", + "failed to open", + "failed to create", } // permissionPane maps a recorder stream to the exact System Settings pane the From 1d78c8c63d16c1bdcc6e956c960c2fc8df0e7f0e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:51:35 +0000 Subject: [PATCH 03/26] fix: write the offset sidecar atomically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WriteFileNoFollow opens with O_TRUNC, so the prior sidecar's bytes were destroyed before the new ones were written; a write failure in between left a truncated audio.offset.json behind — and because the conversion rollback triggers only on a sidecar write that succeeded, the truncated file was never restored. Every later bare transcribe then refused on the unreadable sidecar, with the prior offset unrecoverable from the session. writeOffsetSidecar (and the rollback restore) now go through a new session.WriteFileAtomicNoFollow: same-directory temp file plus rename, refusing a planted symlink up front like WriteFileNoFollow, surfacing the close error before the rename so a deferred write-back failure cannot rename a corrupt temp file into place. Assisted-by: Claude:claude-fable-5 --- internal/session/session.go | 47 ++++++++++++++++++++++++ internal/session/session_test.go | 50 ++++++++++++++++++++++++++ internal/transcribe/transcribe.go | 13 ++++--- internal/transcribe/transcribe_test.go | 35 ++++++++++++++++++ 4 files changed, 141 insertions(+), 4 deletions(-) diff --git a/internal/session/session.go b/internal/session/session.go index 4b2b01a..a90c7ad 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -252,6 +252,53 @@ func WriteFileNoFollow(path string, data []byte, perm os.FileMode) error { return f.Close() } +// WriteFileAtomicNoFollow replaces path with data all-or-nothing: the bytes +// are written to a same-directory temp file and renamed into place, so a +// failure at any point leaves whatever was at path untouched. WriteFileNoFollow +// cannot promise that — its O_TRUNC open destroys the prior bytes before the +// first write, so a write that then fails leaves a truncated file AND an error, +// which for the offset sidecar meant the one durable record of an external +// audio's clock offset was gone with nothing to roll back to. The no-follow +// guarantee is kept by refusing up front when path names a symlink (rename +// would otherwise replace the planted link rather than follow it, but refusal +// matches WriteFileNoFollow so a hostile session directory cannot even retarget +// the name); the temp file is created fresh in the same directory, so the +// rename never crosses a filesystem. +func WriteFileAtomicNoFollow(path string, data []byte, perm os.FileMode) error { + if fi, err := os.Lstat(path); err == nil && fi.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("refusing to write %s: it is a symlink", path) + } + f, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + tmp := f.Name() + cleanup := func() { + f.Close() + os.Remove(tmp) + } + if err := f.Chmod(perm); err != nil { + cleanup() + return err + } + if _, err := f.Write(data); err != nil { + cleanup() + return err + } + // Close before rename, and surface the Close error: a filesystem that + // defers write-back errors to close would otherwise rename a corrupt temp + // file into place (see WriteJSONL's identical stance). + if err := f.Close(); err != nil { + os.Remove(tmp) + return err + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return err + } + return nil +} + // SafeText neutralises untrusted text before it is written into a human-facing // artefact (report.md) or a terminal line (review). It strips C0/C1 control // bytes — including the newline and carriage return that could forge report diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 41f65a6..7851739 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -510,3 +510,53 @@ func TestLoadManifestAcceptsOrdinary(t *testing.T) { t.Fatalf("want session s, got %q", m.Session) } } + +// TestWriteFileAtomicNoFollowRefusesSymlink keeps the atomic write under the +// same planted-symlink refusal as WriteFileNoFollow: rename would replace the +// link rather than follow it, but a hostile session directory must not be able +// to retarget an artefact name at all. +func TestWriteFileAtomicNoFollowRefusesSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target") + if err := os.WriteFile(target, []byte("original\n"), 0o644); err != nil { + t.Fatalf("write target: %v", err) + } + link := filepath.Join(dir, "link") + if err := os.Symlink(target, link); err != nil { + t.Fatalf("symlink: %v", err) + } + if err := WriteFileAtomicNoFollow(link, []byte("clobber\n"), 0o644); err == nil { + t.Fatal("WriteFileAtomicNoFollow wrote through a symlink; want refusal") + } + got, err := os.ReadFile(target) + if err != nil || string(got) != "original\n" { + t.Fatalf("symlink target disturbed: %q, %v", got, err) + } + if _, err := os.Lstat(link); err != nil { + t.Fatalf("planted symlink removed: %v", err) + } +} + +// TestWriteFileAtomicNoFollowReplaces covers the happy path: content replaced, +// no temp file left behind. +func TestWriteFileAtomicNoFollowReplaces(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "f") + if err := os.WriteFile(path, []byte("old\n"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + if err := WriteFileAtomicNoFollow(path, []byte("new\n"), 0o644); err != nil { + t.Fatalf("WriteFileAtomicNoFollow: %v", err) + } + got, err := os.ReadFile(path) + if err != nil || string(got) != "new\n" { + t.Fatalf("content: %q, %v", got, err) + } + names, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("readdir: %v", err) + } + if len(names) != 1 { + t.Fatalf("temp file left behind: %v", names) + } +} diff --git a/internal/transcribe/transcribe.go b/internal/transcribe/transcribe.go index de253cb..50a8552 100644 --- a/internal/transcribe/transcribe.go +++ b/internal/transcribe/transcribe.go @@ -162,7 +162,7 @@ func Run(opts Options) (int, error) { if wrote { switch { case priorCaptured: - _ = session.WriteFileNoFollow(sidecar, prior, 0o644) + _ = session.WriteFileAtomicNoFollow(sidecar, prior, 0o644) case !priorExists: _ = os.Remove(sidecar) } @@ -314,14 +314,19 @@ const maxOffsetSidecarBytes = 64 << 10 const maxOffsetSeconds = 1e9 // writeOffsetSidecar persists offset (with its provenance, for the operator) -// beside audio.wav via the no-follow write guard, so a session's own directory -// cannot redirect the write through a planted symlink. +// beside audio.wav. The write is atomic (temp + rename) as well as +// no-follow: a plain truncating write destroyed the prior sidecar's bytes +// before writing the new ones, so a write failure in between left a +// truncated sidecar behind — and because the caller's rollback triggers only +// on a write that SUCCEEDED before the conversion failed, that truncated +// file was never restored: every later bare transcribe then refused on the +// unreadable sidecar, with the prior offset unrecoverable from the session. func writeOffsetSidecar(dir string, offset float64, provenance string) error { b, err := json.Marshal(offsetSidecar{OffsetSeconds: offset, Provenance: provenance}) if err != nil { return err } - return session.WriteFileNoFollow(filepath.Join(dir, session.AudioOffsetFile), append(b, '\n'), 0o644) + return session.WriteFileAtomicNoFollow(filepath.Join(dir, session.AudioOffsetFile), append(b, '\n'), 0o644) } // offsetSidecarExists reports whether a sidecar is already present beside diff --git a/internal/transcribe/transcribe_test.go b/internal/transcribe/transcribe_test.go index d738c35..c20b586 100644 --- a/internal/transcribe/transcribe_test.go +++ b/internal/transcribe/transcribe_test.go @@ -1249,3 +1249,38 @@ func TestResolveModelAcceptsRegularFile(t *testing.T) { t.Fatalf("want %q, got %q", model, got) } } + +// TestWriteOffsetSidecarFailurePreservesPrior pins the atomic sidecar write. +// The previous truncating write (O_TRUNC, then write) destroyed the prior +// sidecar's bytes the moment the open succeeded, so a write that failed after +// that point left a truncated sidecar with the rollback skipped — and, in this +// read-only-directory arrangement, no failure at all: the existing file's own +// permissions let the truncating open through, silently replacing the one +// durable record of the offset. With temp + rename the write must instead +// fail without touching the prior bytes. +func TestWriteOffsetSidecarFailurePreservesPrior(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("directory write permissions do not bind root") + } + dir := t.TempDir() + sidecar := filepath.Join(dir, session.AudioOffsetFile) + const prior = `{"offset_seconds":12.5,"provenance":"derived: audio creation_time - manifest t0"}` + "\n" + if err := os.WriteFile(sidecar, []byte(prior), 0o644); err != nil { + t.Fatalf("seed sidecar: %v", err) + } + if err := os.Chmod(dir, 0o555); err != nil { + t.Fatalf("chmod: %v", err) + } + t.Cleanup(func() { os.Chmod(dir, 0o755) }) + + if err := writeOffsetSidecar(dir, 99, "explicit -offset"); err == nil { + t.Fatal("writeOffsetSidecar succeeded in a directory it cannot create the temp file in") + } + got, err := os.ReadFile(sidecar) + if err != nil { + t.Fatalf("read sidecar back: %v", err) + } + if string(got) != prior { + t.Fatalf("prior sidecar bytes not preserved:\ngot %q\nwant %q", got, prior) + } +} From f73f2415a81d14e0615959f9c8eb7e51e65be160 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:53:11 +0000 Subject: [PATCH 04/26] fix: print the bound address for an ephemeral capture port demo -addr :0 printed the unopenable http://localhost:0 and exited 0: the displayed URL came from the requested address, while the real port was knowable only outside the tool. Serve now records the bound address on the returned http.Server (keeping the requested host, taking the port from the listener), and demo.Run and record's status line print that. For any explicit port the two agree and nothing changes. Assisted-by: Claude:claude-fable-5 --- internal/demo/demo.go | 24 +++++++++++++++++++++++- internal/demo/demo_test.go | 27 +++++++++++++++++++++++++++ internal/record/record.go | 9 ++++++++- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/internal/demo/demo.go b/internal/demo/demo.go index da18362..dd42b55 100644 --- a/internal/demo/demo.go +++ b/internal/demo/demo.go @@ -98,7 +98,7 @@ func Run(addr, outRoot string) error { testimony merge -session %s testimony report -session %s -`, dir, DisplayURL(addr), dir, dir, dir) +`, dir, DisplayURL(displayAddr(srv, addr)), dir, dir, dir) // Block until interrupted, then shut the server down cleanly. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) @@ -195,6 +195,17 @@ func Serve(addr, dir string) (*http.Server, error) { ReadTimeout: readTimeout, IdleTimeout: idleTimeout, } + // Surface the actually-bound address on the returned server, keeping the + // operator's requested host but taking the port from the listener: ":0" + // binds an ephemeral port only net.Listen knows, and printing the requested + // addr rendered the unopenable http://localhost:0 while the real port was + // discoverable only outside the tool. For any explicit port the two agree + // and srv.Addr equals the requested addr. + if _, boundPort, perr := net.SplitHostPort(ln.Addr().String()); perr == nil { + if reqHost, _, herr := net.SplitHostPort(addr); herr == nil { + srv.Addr = net.JoinHostPort(reqHost, boundPort) + } + } go srv.Serve(ln) return srv, nil } @@ -414,6 +425,17 @@ func isJSONContentType(ct string) bool { return strings.EqualFold(strings.TrimSpace(ct), "application/json") } +// displayAddr returns the address to print for a running capture server: the +// bound address Serve recorded on the server when available (which differs +// from the request only when an ephemeral ":0" port was asked for), else the +// requested one. +func displayAddr(srv *http.Server, requested string) string { + if srv != nil && srv.Addr != "" { + return srv.Addr + } + return requested +} + // DisplayURL renders the human-facing URL an operator opens for a capture // server bound to addr. It shows "localhost" only for the host-less default // (":8737" -> http://localhost:8737); when an operator passes an explicit host diff --git a/internal/demo/demo_test.go b/internal/demo/demo_test.go index 3b631a3..25a3bab 100644 --- a/internal/demo/demo_test.go +++ b/internal/demo/demo_test.go @@ -3,6 +3,7 @@ package demo import ( "errors" "io" + "net" "net/http" "net/http/httptest" "os" @@ -567,3 +568,29 @@ func TestServeRefusesSymlinkStream(t *testing.T) { t.Fatalf("victim file was modified through the symlink: %q", b) } } + +// TestServeSurfacesBoundAddr pins the ephemeral-port fix: with ":0" the +// requested address names no real port, so Serve records the bound one on the +// returned server for callers to print — previously demo -addr :0 printed the +// unopenable http://localhost:0 while the real port was knowable only outside +// the tool. An explicit port must round-trip unchanged. +func TestServeSurfacesBoundAddr(t *testing.T) { + srv, err := Serve(":0", manifestDir(t)) + if err != nil { + t.Fatalf("Serve: %v", err) + } + defer Shutdown(srv) + host, port, err := net.SplitHostPort(srv.Addr) + if err != nil { + t.Fatalf("srv.Addr %q: %v", srv.Addr, err) + } + if host != "" { + t.Fatalf("requested host must be kept (empty for \":0\"), got %q", host) + } + if port == "0" || port == "" { + t.Fatalf("bound port not surfaced: srv.Addr = %q", srv.Addr) + } + if got := DisplayURL(displayAddr(srv, ":0")); strings.Contains(got, ":0") || !strings.HasPrefix(got, "http://localhost:") { + t.Fatalf("display URL still unopenable: %q", got) + } +} diff --git a/internal/record/record.go b/internal/record/record.go index 1e987d8..eb6eb40 100644 --- a/internal/record/record.go +++ b/internal/record/record.go @@ -158,7 +158,14 @@ func Run(opts Options) error { } } - printStatus(opts.Log, recorders, opts.Demo, opts.Addr) + // Print the address the demo server actually bound (Serve records it on + // the returned server), not the requested one: with -addr :0 the requested + // form renders the unopenable http://localhost:0. + statusAddr := opts.Addr + if srv != nil && srv.Addr != "" { + statusAddr = srv.Addr + } + printStatus(opts.Log, recorders, opts.Demo, statusAddr) // Nothing is running to wait on (degraded platform, no demo): the session // dir and manifest are written; print next steps and exit cleanly. From 203ac34fd0b31ca92e1b1221d0f39df0db478f9a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:53:11 +0000 Subject: [PATCH 05/26] fix: name the offending WriteJSONL record as an output line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The over-long-record refusal printed 'record %d' with the 0-based index into the caller's merged, time-sorted slice — 'record 0' is a line of no file, and no line of the source transcript either. The message now names the 1-based line of the file being written, the only position an operator can count to. Assisted-by: Claude:claude-fable-5 --- internal/session/session.go | 6 +++++- internal/session/session_test.go | 7 +++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/internal/session/session.go b/internal/session/session.go index a90c7ad..4dd79bb 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -416,7 +416,11 @@ func WriteJSONL[T any](path string, values []T) error { // tooLongForJSONL draws the boundary on the same side, so the capture and // artefact writers accept exactly the same set of records. if buf.Len() > MaxJSONLLine { - return fmt.Errorf("%s: record %d encodes to %d bytes, over the %d-byte JSONL line limit", path, i, buf.Len(), MaxJSONLLine) + // 1-based, and named as a line of the file being written: the caller's + // slice is already merged and time-sorted, so a 0-based slice index + // pointed the operator at nothing they could count to — "record 0" is + // no line of any file, and no line of the source transcript either. + return fmt.Errorf("%s: line %d of the output encodes to %d bytes, over the %d-byte JSONL line limit", path, i+1, buf.Len(), MaxJSONLLine) } } diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 7851739..feb67c9 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -276,8 +276,11 @@ func TestWriteJSONLRefusesOverlongRecord(t *testing.T) { if err == nil { t.Fatal("WriteJSONL persisted a record over MaxJSONLLine; want refusal") } - if !strings.Contains(err.Error(), "record 0") { - t.Errorf("error does not name the offending index: %v", err) + // The offending record is named as a 1-based line of the output file — the + // only position an operator can count to. The earlier "record 0" was a + // 0-based index into the caller's already-sorted slice, a line of no file. + if !strings.Contains(err.Error(), "line 1 of the output") { + t.Errorf("error does not name the offending output line: %v", err) } // The earlier artefact is intact: nothing was written, not even a truncation. From fb13e112bea839cd79182722473dbaddcecaa554 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:53:11 +0000 Subject: [PATCH 06/26] fix: default transcribe's log sink instead of panicking on nil record.Run defaults Options.Log to os.Stderr; transcribe.Run wrote its offset status line straight to opts.Log, so a caller leaving Log nil panicked at the first Fprintf. Default it the same way. Assisted-by: Claude:claude-fable-5 --- internal/transcribe/transcribe.go | 5 +++++ internal/transcribe/transcribe_test.go | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/internal/transcribe/transcribe.go b/internal/transcribe/transcribe.go index 50a8552..41ae2c2 100644 --- a/internal/transcribe/transcribe.go +++ b/internal/transcribe/transcribe.go @@ -56,6 +56,11 @@ type segment struct { // Run performs the full pipeline and returns the number of utterances // written to transcript.jsonl in the session directory. func Run(opts Options) (int, error) { + // Default the log sink as record.Run does, so a caller that leaves Log nil + // gets progress on stderr instead of a panic at the first status line. + if opts.Log == nil { + opts.Log = os.Stderr + } man, err := session.LoadManifest(opts.SessionDir) if err != nil { return 0, err diff --git a/internal/transcribe/transcribe_test.go b/internal/transcribe/transcribe_test.go index c20b586..73d9bad 100644 --- a/internal/transcribe/transcribe_test.go +++ b/internal/transcribe/transcribe_test.go @@ -1284,3 +1284,15 @@ func TestWriteOffsetSidecarFailurePreservesPrior(t *testing.T) { t.Fatalf("prior sidecar bytes not preserved:\ngot %q\nwant %q", got, prior) } } + +// TestRunDefaultsNilLog pins the log-sink default: record.Run defaults its +// Log to os.Stderr, but transcribe.Run wrote its offset status line straight +// to opts.Log, so a caller leaving Log nil panicked at the first Fprintf. +func TestRunDefaultsNilLog(t *testing.T) { + fakeTools(t) + dir, _ := seedSession(t, session.Manifest{Session: "s", T0EpochMS: 1_700_000_000_000}) + + if _, err := Run(Options{SessionDir: dir, Engine: EngineWhisperX}); err != nil { + t.Fatalf("Run with nil Log: %v", err) + } +} From e7c3ec1fdbbbe096864819fdd857b17eb01a60bb Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:54:50 +0000 Subject: [PATCH 07/26] fix: cut diagnostic tails on rune boundaries The three bounded stderr tails (record's outputTail and lockedBuffer.tail, transcribe's tail) sliced at a byte offset, so a non-ASCII device or file name straddling the cut opened the operator-facing message with an invalid UTF-8 fragment. Advance the cut to the next rune start. Assisted-by: Claude:claude-fable-5 --- internal/record/proc.go | 10 +++++++++- internal/record/record_test.go | 22 ++++++++++++++++++++++ internal/record/recorders.go | 10 +++++++++- internal/transcribe/transcribe.go | 10 +++++++++- internal/transcribe/transcribe_test.go | 11 +++++++++++ 5 files changed, 60 insertions(+), 3 deletions(-) diff --git a/internal/record/proc.go b/internal/record/proc.go index c90fa60..f43d404 100644 --- a/internal/record/proc.go +++ b/internal/record/proc.go @@ -6,6 +6,7 @@ import ( "sync" "syscall" "time" + "unicode/utf8" ) // proc is the minimal process surface the lifecycle drives, so the SIGINT → @@ -100,7 +101,14 @@ func (l *lockedBuffer) tail() string { const max = 1200 b := l.buf if len(b) > max { - return "…" + string(b[len(b)-max:]) + // Advance the cut to the next rune boundary so the tail never opens + // mid-rune: a split UTF-8 sequence (a non-ASCII device name in ffmpeg's + // listing straddling the cut) renders as replacement garbage. + i := len(b) - max + for i < len(b) && !utf8.RuneStart(b[i]) { + i++ + } + return "…" + string(b[i:]) } if l.dropped { return "…" + string(b) diff --git a/internal/record/record_test.go b/internal/record/record_test.go index a3475cd..b69e7aa 100644 --- a/internal/record/record_test.go +++ b/internal/record/record_test.go @@ -16,6 +16,7 @@ import ( "syscall" "testing" "time" + "unicode/utf8" "github.com/REPPL/Testimony/internal/demo" "github.com/REPPL/Testimony/internal/session" @@ -1204,3 +1205,24 @@ func TestClassifyRecorderExitBannerIsNotAPermissionsSignature(t *testing.T) { } } } + +// TestTailsCutOnRuneBoundaries pins the rune-aligned truncation of the two +// diagnostic tails in this package: a byte-offset cut could open the tail +// mid-rune (a non-ASCII device name straddling it), rendering replacement +// garbage at the head of an operator-facing message. +func TestTailsCutOnRuneBoundaries(t *testing.T) { + // 2-byte runes with one trailing ASCII byte put every rune start on an even + // offset while the cut lands on an odd one — mid-rune before the fix. + long := strings.Repeat("é", 700) + "a" + + if got := outputTail([]byte(long)); !utf8.ValidString(got) { + t.Fatalf("outputTail split a rune: %q...", got[:12]) + } + var lb lockedBuffer + if _, err := lb.Write([]byte(long)); err != nil { + t.Fatalf("Write: %v", err) + } + if got := lb.tail(); !utf8.ValidString(got) { + t.Fatalf("lockedBuffer.tail split a rune: %q...", got[:12]) + } +} diff --git a/internal/record/recorders.go b/internal/record/recorders.go index 8cbe9e1..ae3286e 100644 --- a/internal/record/recorders.go +++ b/internal/record/recorders.go @@ -9,6 +9,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" ) // deviceListTimeout bounds the avfoundation device enumeration. It is a var, not a @@ -261,7 +262,14 @@ func outputTail(out []byte) string { const max = 400 s := strings.TrimSpace(string(out)) if len(s) > max { - s = "..." + s[len(s)-max:] + // Advance the cut to the next rune boundary: a byte-offset cut can open + // the tail mid-rune (a non-ASCII device or file name straddling it), and + // a split UTF-8 sequence renders as replacement garbage in the message. + i := len(s) - max + for i < len(s) && !utf8.RuneStart(s[i]) { + i++ + } + s = "..." + s[i:] } return s } diff --git a/internal/transcribe/transcribe.go b/internal/transcribe/transcribe.go index 41ae2c2..6908fd2 100644 --- a/internal/transcribe/transcribe.go +++ b/internal/transcribe/transcribe.go @@ -18,6 +18,7 @@ import ( "os" "path/filepath" "strings" + "unicode/utf8" "github.com/REPPL/Testimony/internal/session" "github.com/REPPL/Testimony/internal/timeline" @@ -456,7 +457,14 @@ func tail(b []byte) string { s := strings.TrimSpace(string(b)) const max = 800 if len(s) > max { - s = "…" + s[len(s)-max:] + // Advance the cut to the next rune boundary so the tail never opens + // mid-rune: a split UTF-8 sequence (a non-ASCII path in the engine's + // output straddling the cut) renders as replacement garbage. + i := len(s) - max + for i < len(s) && !utf8.RuneStart(s[i]) { + i++ + } + s = "…" + s[i:] } return s } diff --git a/internal/transcribe/transcribe_test.go b/internal/transcribe/transcribe_test.go index 73d9bad..0d222da 100644 --- a/internal/transcribe/transcribe_test.go +++ b/internal/transcribe/transcribe_test.go @@ -10,6 +10,7 @@ import ( "syscall" "testing" "time" + "unicode/utf8" "github.com/REPPL/Testimony/internal/session" "github.com/REPPL/Testimony/internal/timeline" @@ -1296,3 +1297,13 @@ func TestRunDefaultsNilLog(t *testing.T) { t.Fatalf("Run with nil Log: %v", err) } } + +// TestTailCutsOnRuneBoundary pins the rune-aligned truncation of the engine +// diagnostic tail (see the identical property in record's tails): a byte-offset +// cut could open the tail mid-rune and render replacement garbage. +func TestTailCutsOnRuneBoundary(t *testing.T) { + long := strings.Repeat("é", 450) + "a" + if got := tail([]byte(long)); !utf8.ValidString(got) { + t.Fatalf("tail split a rune: %q...", got[:12]) + } +} From 2253aef7e15fbb1ff98d4a62e6b6a836d76241a7 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:55:36 +0000 Subject: [PATCH 08/26] fix: fall back to fetch when sendBeacon refuses a capture batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit navigator.sendBeacon returns false (it does not throw) when the UA refuses to queue the body — most commonly the in-flight beacon quota, which a large rrweb batch can hit — and both call sites had already drained the buffer with splice, so the batch was lost with no trace: nothing reached the server, so the stderr refusal log could not see the drop either. The demo page (and the how-to's copyable snippet) now check the return and fall back to fetch. Also guard labelFor against a non-Element target, matching selectorFor. Assisted-by: Claude:claude-fable-5 --- docs/how-to/instrument-your-own-app.md | 13 ++++++++++--- internal/demo/assets/index.html | 17 ++++++++++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/how-to/instrument-your-own-app.md b/docs/how-to/instrument-your-own-app.md index 31e1f85..d47d14d 100644 --- a/docs/how-to/instrument-your-own-app.md +++ b/docs/how-to/instrument-your-own-app.md @@ -56,9 +56,16 @@ A minimal capture script, following the same conventions as the demo app — pre } function post(url, body) { try { - navigator.sendBeacon - ? navigator.sendBeacon(url, new Blob([JSON.stringify(body)], { type: "application/json" })) - : fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), keepalive: true }); + var json = JSON.stringify(body); + // sendBeacon returns false when the UA refuses to queue the body + // (e.g. the in-flight beacon quota): fall back to fetch so the + // records are not lost silently. + var queued = navigator.sendBeacon + ? navigator.sendBeacon(url, new Blob([json], { type: "application/json" })) + : false; + if (!queued) { + fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: json, keepalive: true }); + } } catch (e) { /* capture must never break the app */ } } function interaction(kind, el, extra) { diff --git a/internal/demo/assets/index.html b/internal/demo/assets/index.html index 46c2fb8..9f14188 100644 --- a/internal/demo/assets/index.html +++ b/internal/demo/assets/index.html @@ -146,14 +146,25 @@

About

return s; } function labelFor(el) { + if (!(el instanceof Element)) return ""; var t = (el.closest("[data-testid]") || el).textContent || ""; return t.trim().replace(/\s+/g, " ").slice(0, 40); } function post(url, body) { try { - navigator.sendBeacon - ? navigator.sendBeacon(url, new Blob([JSON.stringify(body)], { type: "application/json" })) - : fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), keepalive: true }); + var json = JSON.stringify(body); + // sendBeacon returns false (it does not throw) when the UA refuses to + // queue the body — most commonly the in-flight beacon quota, which a + // large rrweb batch can hit. By then the batch is already out of the + // buffer, and nothing reaches the server, so its stderr refusal log + // cannot see the drop either: fall back to fetch instead of losing the + // records silently. + var queued = navigator.sendBeacon + ? navigator.sendBeacon(url, new Blob([json], { type: "application/json" })) + : false; + if (!queued) { + fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: json, keepalive: true }); + } } catch (e) { /* capture must never break the app under test */ } } function interaction(kind, el, extra) { From 1902ffe989051942e8453a142cabf59744ed7148 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:55:36 +0000 Subject: [PATCH 09/26] fix: seed the demo display name with the Alice persona The bundled demo app seeded its display-name field with a name outside the repository's Alice/Bob/Carol persona set, and the tutorial narrating this exact interaction has Alice changing the name to 'Alice'. Seed it as Alice. Assisted-by: Claude:claude-fable-5 --- internal/demo/assets/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/demo/assets/index.html b/internal/demo/assets/index.html index 9f14188..90572d0 100644 --- a/internal/demo/assets/index.html +++ b/internal/demo/assets/index.html @@ -81,7 +81,7 @@

General

Shown on your reports
- +
Sent on Mondays
From ca39e13ac41e88e2cda0c4c8056e428463a520eb Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:56:42 +0000 Subject: [PATCH 10/26] docs: state the installer's fail-closed attestation contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trust-model comments said only a verification gh performs and rejects refuses the install, but the implementation also refuses when gh cannot complete the verification (network or API failure) — and rightly so: an inconclusive verification cannot be told apart from one an attacker is blocking, and that attacker could also serve a substituted tarball with a matching SHA256SUMS. Align the two comment blocks with the fail-closed behavior instead of loosening it. Assisted-by: Claude:claude-fable-5 --- install.sh | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/install.sh b/install.sh index 85e5c5d..6b06a49 100644 --- a/install.sh +++ b/install.sh @@ -27,7 +27,11 @@ # was built by REPPL/Testimony's own release.yml, the strong anchor). A gh that # cannot attempt the verification — absent, unauthenticated, or too old to know # attestations — means the install proceeds on the checksum alone, with a note -# saying so; only a verification gh performs and rejects refuses the install. +# saying so. Any other gh failure refuses the install, fail closed: that covers +# a verification gh performed and rejected, and equally one it could not +# complete (the attestation API unreachable), because an inconclusive +# verification cannot be told apart from one an attacker is blocking. Re-run +# when connectivity returns. # No per-release hash is pinned in this script: the checksums are fetched from # the release itself and the attestation binds them to the workflow. # Everything installs into user-owned locations by default; sudo is never invoked. @@ -163,8 +167,12 @@ Refusing to install." # command or --signer-workflow fails the same way — treating those as # "attestation FAILED" made a freshly `brew install`ed gh strictly worse # than no gh at all, refusing a tarball whose checksum had just verified. - # Only a verification gh actually performed and rejected refuses the - # install, and gh's own output is shown instead of being swallowed. + # Every other failure refuses the install, fail closed — a rejected + # attestation, and equally a verification gh could not complete (network, + # API outage): an inconclusive verification cannot be told apart from one + # an attacker is blocking, and that attacker is exactly the one who could + # also serve a substituted tarball with a matching SHA256SUMS. gh's own + # output is shown instead of being swallowed. if have gh && ! gh auth status --hostname github.com >/dev/null 2>&1; then say "NOTE: 'gh' is installed but not authenticated — installed on the checksum alone." say " 'gh attestation verify' needs an authenticated gh; run 'gh auth login'" From a39fbd42d6ddbfa50773543cd55152c1c464976c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:56:42 +0000 Subject: [PATCH 11/26] fix: verify the installed binary runs and names the pinned release A failing command substitution inside say's argument does not trip set -e, so an unrunnable installed binary (a wrong-platform asset, a noexec mount) printed 'Installed: ... ()' and exited 0. Capture the binary's own version output, refuse the install when it fails to run or reports anything other than 'testimony $VERSION', and print the captured value in the success line. Assisted-by: Claude:claude-fable-5 --- install.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 6b06a49..bf188b4 100644 --- a/install.sh +++ b/install.sh @@ -202,7 +202,13 @@ Refusing to install." mkdir -p "$INSTALL_DIR" tar -xzf "$tmp/$tarball" -C "$tmp" testimony install -m 0755 "$tmp/testimony" "$INSTALL_DIR/testimony" - say "Installed: $INSTALL_DIR/testimony ($("$INSTALL_DIR/testimony" version))" + # Prove the installed binary runs and is the release it claims before + # announcing success: a failing command substitution inside say's argument + # does not trip `set -e`, so an unrunnable binary (a wrong-platform asset, + # a noexec mount) previously printed "Installed: ... ()" and exited 0. + installed_version="$("$INSTALL_DIR/testimony" version)" || die "the installed binary failed to run: $INSTALL_DIR/testimony" + [ "$installed_version" = "testimony $VERSION" ] || die "the installed binary reports \"$installed_version\", expected \"testimony $VERSION\"" + say "Installed: $INSTALL_DIR/testimony ($installed_version)" case ":$PATH:" in *":$INSTALL_DIR:"*) : ;; From 92b1f003f22f061159d7a952b070d4493f1b13f6 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:57:22 +0000 Subject: [PATCH 12/26] chore: gate the event half of the pipeline smoke and the release stamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The smoke assertions were all satisfiable from the transcript and findings alone — deleting interactions.jsonl left every one green — so a regression that dropped all events, or broke the utterance-event join, passed both ci and the release verify gate. Assert the exact header counts and an event-only selector string in both workflows. Also close two adjacent gaps: parse install.sh (sh -n, bash -n) in ci, since it is served live from main as the documented install path with no syntax gate; and, post-release, download a published tarball and require the binary's own version output to name the tag — the Go linker silently ignores a -X flag whose symbol does not exist, so a renamed internal/cli.Version would ship every tarball as 'testimony dev' with the release green. Assisted-by: Claude:claude-fable-5 --- .github/workflows/ci.yml | 18 ++++++++++++++++++ .github/workflows/release.yml | 29 +++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d4a5cd..eae00d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,24 @@ jobs: grep -q "## Findings" examples/sample-session/report.md grep -q "### Confirmed (1)" examples/sample-session/report.md grep -q "\*\*F-001\*\* bug" examples/sample-session/report.md + # The event half of the pipeline: every assertion above is satisfiable + # from the transcript and findings alone (verified: deleting + # interactions.jsonl left them all green), so the header counts and an + # event-only selector string gate the interactions → timeline → report + # path too. The exact counts pin the bundled fixture; extending the + # sample session means updating them here and in release.yml. + grep -q "\*\*Utterances:\*\* 10 · \*\*Events:\*\* 10" examples/sample-session/report.md + grep -q "data-testid=save-btn" examples/sample-session/report.md + + # install.sh is served live from main as the documented install path, so + # a merged syntax error breaks `curl | sh` for every new user with no + # gate having run. Parse it with both shells an operator realistically + # pipes it into. + - name: Installer syntax + run: | + set -euo pipefail + sh -n install.sh + bash -n install.sh # Secrets never land in history: full-history scan on every push/PR. Run as a # pinned, checksum-verified CLI (no PR-comment / SARIF GitHub-API integration diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ee7c7f..82df5b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -123,6 +123,9 @@ jobs: grep -q "## Findings" examples/sample-session/report.md grep -q "### Confirmed (1)" examples/sample-session/report.md grep -q "\*\*F-001\*\* bug" examples/sample-session/report.md + # The event half of the pipeline — see ci.yml's identical assertions. + grep -q "\*\*Utterances:\*\* 10 · \*\*Events:\*\* 10" examples/sample-session/report.md + grep -q "data-testid=save-btn" examples/sample-session/report.md # Build and publish ONLY after verify is green. Tarballs are cross-compiled from # the same pushed commit (github.sha) that verify just gated, so the shipped @@ -249,6 +252,32 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Assert the released binary reports the tag + # The version stamp rides on the -X flag naming internal/cli.Version, + # and the Go linker silently ignores -X for a symbol that does not + # exist — rename or move the variable and every tarball ships + # reporting "testimony dev" while the release stays green, the first + # observer being an end user. Download one tarball fresh from the + # published Release and require the binary's own version output to + # name the tag. Unlike the attestation step above this runs on private + # repos too, so it is a separate step with its own download. + run: | + set -euo pipefail + DL="$RUNNER_TEMP/version-check" + mkdir -p "$DL" + gh release download "$TAG" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "testimony_${TAG}_linux_amd64.tar.gz" \ + --dir "$DL" + tar -xzf "$DL/testimony_${TAG}_linux_amd64.tar.gz" -C "$DL" testimony + got="$("$DL/testimony" version)" + if [ "$got" != "testimony $TAG" ]; then + echo "released binary reports \"$got\", expected \"testimony $TAG\"" >&2 + exit 1 + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Assert the release job pushed nothing to the default branch # Close the no-branch-commit tripwire opened at job start. Runs last so it # covers every preceding step. !cancelled(): the tripwire must still run when From 620fe25e7dfd38818ffd26b97116d7bce9ce6bdd Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:00:35 +0000 Subject: [PATCH 13/26] docs: correct reference and how-to claims against the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - session-directory.md and the report entry: the header duration is the latest moment on the timeline (t1 of the last utterance when speech closes the session), not 'from the last entry'; report's entry names its inputs (manifest.json required, findings.jsonl when present) as the sibling entries do. - record's entry states the ffmpeg prerequisite README and the installer already state. - instrument-your-own-app.md and the allowWrite doc comment describe the Origin guard as loopback (any loopback host, whatever its port), which is what the code enforces; only cli.md had it right. - analyse-a-session.md names the non-interactive review form as the way to change a recorded verdict; the interactive walk offers only findings still unverified. - how-alignment-works.md scopes the word-timestamp claim to what the join consumes: whole-utterance boundaries, sharpened by alignment — no word-level anchoring knob exists. - README states that voice and screen capture need macOS, and its pipeline diagram corners now join up. - CHANGELOG: the preamble no longer promises a Breaking section no release has (breaks are called out under Changed); the Unreleased groups drop internal bug-hunt round labels and merge the duplicated invocation-contract group; round-10 entries added. - DECISIONS.md records the round. Assisted-by: Claude:claude-fable-5 --- .abcd/work/DECISIONS.md | 15 ++++++ CHANGELOG.md | 69 ++++++++++++++++++++++--- README.md | 10 ++-- docs/explanation/how-alignment-works.md | 2 +- docs/how-to/analyse-a-session.md | 6 ++- docs/how-to/instrument-your-own-app.md | 2 +- docs/reference/cli.md | 4 +- docs/reference/session-directory.md | 2 +- internal/demo/demo.go | 4 +- 9 files changed, 94 insertions(+), 20 deletions(-) diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index ed7365b..b4d2c01 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -268,3 +268,18 @@ Architecture-shaping decisions graduate to an ADR under (endpoint preconditions, evidence cap, sessionEnd bound, ffmpeg's real consumers, the demo page's CDN fetch disclosed in privacy). Vendoring rrweb (offline capture) needs a dependency decision — reported, not done. +- 2026-07-29 — Bug-hunt round 10: four hunters, two adversarial refuters. + 3 substantive and 22 nitpick findings confirmed and fixed; 3 candidates + refuted (the -audio sidecar wording, the mode default claim, the + line-wrap premise). Headlines: analyze refuses duplicate timeline ids + (the id-keyed quote validator paired quotes with the wrong moment) and, + with report, refuses unknown-src entries instead of silently omitting + them; the recorder permissions headline fires only on a device-open + failure, not on ffmpeg's ordinary banner; the offset sidecar writes + atomically; the CI smoke gains event-half assertions and the release + workflow asserts the shipped binary names the tag; install.sh verifies + the installed binary before announcing success. Recorded, not fixed: + the VERSION-pin window before a tag exists (process), docs-lint gates + not running in CI (tooling), doubled CI runs on PR branches (trigger + set is tied to the merge queue), and the /dev/null TTY gate (per + round 8). diff --git a/CHANGELOG.md b/CHANGELOG.md index d3eb42e..fc25bcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,71 @@ All notable changes to Testimony are recorded here. The format follows uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a leading `v`. -Before v1.0.0, minor releases may make breaking changes; each one is -called out in a **Breaking** section. +Before v1.0.0, minor releases may make breaking changes; a change that can +break an existing invocation is called out where it is recorded (to date, +under **Changed**). ## [Unreleased] ### Fixed -Invocation contract (from the round-8 bug-hunt): +Evidence integrity: + +- `analyze` refuses a timeline carrying duplicate entry ids: of two + utterances sharing an id, only the later one reached the quote validator, + so an honest verbatim quote of the first was rejected while a quote of the + second validated for a finding anchored at the first one's time. `report`, + whose join is positional, still renders such a file. +- `report` and `analyze` refuse a timeline entry whose `src` is neither + `speech` nor `event`, instead of dropping it from the rendered timeline + while counting its time into the header duration and keeping its id + citable as evidence. +- The audio offset sidecar is written atomically (temp file and rename): a + write failure part-way through used to leave a truncated sidecar behind, + blocking every later bare `transcribe` with the prior offset + unrecoverable from the session. + +Capture and diagnostics: + +- A recorder start-up failure is headlined as a likely permissions issue + only when the ffmpeg output carries a device-open failure; the module + banner alone — printed on every successful open — used to route a full + disk or a missing encoder to the System Settings pane. +- `demo -addr :0` prints the actually-bound address instead of the + unopenable `http://localhost:0`; the same applies to `record -demo`. +- The demo page falls back to `fetch` when `sendBeacon` refuses to queue a + capture batch, which used to drop the batch silently; its seeded display + name now uses the Alice persona. +- Diagnostic stderr tails cut on rune boundaries rather than mid-character; + a record refused by the JSONL writer is named as a 1-based line of the + output file; `transcribe` defaults its log sink instead of panicking when + a caller leaves it unset. + +Checks and installer: + +- The pipeline smoke test asserts the event half of the pipeline (the + header counts and an event-only selector); every prior assertion was + satisfiable from the transcript and findings alone, so a regression that + dropped all events kept the gate green. +- The release workflow downloads a published tarball and requires the + binary's own version output to name the tag; the checks parse `install.sh` + with both shells the documented one-liner pipes it into. +- `install.sh` verifies the installed binary runs and reports the pinned + release before announcing success; an unrunnable binary (a wrong-platform + asset, a `noexec` mount) used to print a success line at exit 0. Its + trust-model comments state the fail-closed attestation behaviour the + implementation has always had. + +Documentation: + +- Reference and how-to corrections against the code: the report header + duration definition, `report`'s inputs, `record`'s ffmpeg prerequisite, + the loopback `Origin` rule, the command that changes a recorded verdict, + and the word-timestamp claim scoped to the utterance boundaries the join + operates on. The README states that voice and screen capture need macOS, + and its pipeline diagram joins up. + +Invocation contract: - Every usage error now exits 2, as the CLI reference documents: a missing required `-session`, a disallowed flag combination (`analyze -out` with @@ -33,8 +90,6 @@ Invocation contract (from the round-8 bug-hunt): installer also renders single-option prompts correctly and names the actual cause when a release download fails. -Invocation contract (round 9): - - A stray positional argument is refused as a usage error; it used to be silently swallowed together with every flag after it, so the command ran with defaults at exit 0. @@ -43,7 +98,7 @@ Invocation contract (round 9): `transcribe -engine`, and a malformed capture `-addr` (which also no longer creates a session directory before refusing). -Capture integrity (round 9): +Capture integrity: - `POST /api/interactions` refuses with 400 any record `merge` would refuse — a non-object body, or a missing/implausible `t` or missing `kind` — instead @@ -57,7 +112,7 @@ Capture integrity (round 9): - A recorder that exits on its own mid-session still validates the artefacts the other recorders left and prints the next-command block. -Installer (round 9): +Installer: - An unauthenticated (or attestation-incapable) `gh` no longer refuses the install as a false provenance failure: it falls back to the verified diff --git a/README.md b/README.md index 6937395..a8aac4d 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ timestamped interaction stream, rendered as a report that shows what was said ne to what was done. ``` - voice ──► local Whisper ──► transcript.jsonl ─┐ - ├─► timeline.jsonl ─► report.md - clicks ──► rrweb / hooks ──► interactions.jsonl ┘ + voice ──► local Whisper ──► transcript.jsonl ─────┐ + ├─► timeline.jsonl ─► report.md + clicks ──► rrweb / hooks ──► interactions.jsonl ──┘ ``` Raw audio and video never leave your machine; only derived text is analysed. See @@ -56,7 +56,9 @@ open examples/sample-session/report.md ``` Then capture a real one: `testimony record -demo` starts a capture session — -recording your voice and clicks in one command — and prints every step. The +recording your voice and clicks in one command — and prints every step. Voice +and screen capture need macOS; on Linux, `record` skips those streams and says +so, and an external recording joins the session via `transcribe -audio`. The [getting-started tutorial](docs/tutorials/getting-started.md) walks the whole path — record, think aloud, transcribe, merge, report — in about five minutes. The result interleaves speech with interface events: diff --git a/docs/explanation/how-alignment-works.md b/docs/explanation/how-alignment-works.md index bd5f2d9..9eee0ca 100644 --- a/docs/explanation/how-alignment-works.md +++ b/docs/explanation/how-alignment-works.md @@ -30,4 +30,4 @@ A window — rather than exact matching — is deliberate. Transcription timesta Plain speech recognition emits segment-level times, which can be off by several seconds — as coarse as the join window itself, which makes joining speech to an individual click unreliable. WhisperX adds forced alignment: it pins each recognised word to its own moment in the audio. -That precision is what the pipeline is built around. When a participant says "I expected *this* button to save immediately", the word "this" carries its own timestamp, close to the click on the thing it refers to. Word times make the utterance-to-event join trustworthy at its default width, allow it to be tightened when a single deictic word needs anchoring, and are the reason WhisperX is the preferred engine — whisper.cpp works, but its segment-level times lean harder on the window. +That precision is what the pipeline is built around. When a participant says "I expected *this* button to save immediately", the word "this" carries its own timestamp, close to the click on the thing it refers to. The join itself operates on whole utterances — each utterance's span, widened by the window — so what the alignment buys is trustworthy utterance boundaries: word-accurate segment times keep the join honest at its default width, and they are the reason WhisperX is the preferred engine — whisper.cpp works, but its segment-level times lean harder on the window. diff --git a/docs/how-to/analyse-a-session.md b/docs/how-to/analyse-a-session.md index e409120..bca02d3 100644 --- a/docs/how-to/analyse-a-session.md +++ b/docs/how-to/analyse-a-session.md @@ -104,8 +104,10 @@ open sessions//report.md The Findings section lists findings under **Confirmed**, **Unverified**, **Duplicate**, and **Rejected**, each with its quote, anchor, and — where you -recorded one — the verdict and its date. Re-run `review` any time to change a -verdict; the latest one wins, and the history is kept. +recorded one — the verdict and its date. Change a verdict at any time with +`testimony review -finding F-NNN -verdict ` (the interactive walk +offers only findings that are still unverified); the latest one wins, and the +history is kept. For the exact field rules, see the [session directory reference](../reference/session-directory.md#findingsjsonl); diff --git a/docs/how-to/instrument-your-own-app.md b/docs/how-to/instrument-your-own-app.md index d47d14d..5ad470b 100644 --- a/docs/how-to/instrument-your-own-app.md +++ b/docs/how-to/instrument-your-own-app.md @@ -17,7 +17,7 @@ This creates a fresh session directory (with `manifest.json` anchoring the sessi Both endpoints accept POST only (anything else returns 405) and return `204 No Content` on success. `/api/interactions` caps the body at 4 MiB — the readable JSONL line limit, since one request becomes one line — and rejects with 400 anything the merge step could not read back: invalid JSON, a body that is not a JSON object, or an object missing the required `t` or `kind` from the table below; `/api/events` caps the batch body at 8 MiB and rejects anything that is not a JSON array with 400. A body over its cap gets 413, as does a batch element that would itself exceed the 4 MiB line limit. -To defend the evidence against cross-origin forgery (CSRF) and DNS-rebinding, the write endpoints require `Content-Type: application/json`, a loopback `Host`, and — when present — a same-origin `Origin`. Post from your app's own origin (see the proxy in step 5) and always set the JSON content type, as the snippet below does. Each accepted body is re-encoded to a single line, so one request is always exactly one JSONL record. +To defend the evidence against cross-origin forgery (CSRF) and DNS-rebinding, the write endpoints require `Content-Type: application/json`, a loopback `Host`, and — when present — a loopback `Origin` (any loopback host, whatever its port; anything else gets 403). Post from your app's own origin (see the proxy in step 5) and always set the JSON content type, as the snippet below does. Each accepted body is re-encoded to a single line, so one request is always exactly one JSONL record. ## 2. Add stable `data-testid` attributes diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 5b54925..cb2e8e3 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -97,7 +97,7 @@ testimony report -session DIR [-window 2.5] | `-session` | *(required)* | session directory | | `-window` | `2.5` | utterance-to-event join window, in seconds | -Behaviour: attaches each event to the first utterance whose span, widened by the window on both sides, contains it; events matched by no utterance appear as standalone lines. Writes `report.md` into the session directory and prints `wrote `. +Behaviour: reads `manifest.json` (required) and `timeline.jsonl`, plus `findings.jsonl` when present (the Findings section; without the file it is a short notice pointing at `analyze` and `review`). Attaches each event to the first utterance whose span, widened by the window on both sides, contains it; events matched by no utterance appear as standalone lines. Writes `report.md` into the session directory and prints `wrote `. ## `testimony record` @@ -120,7 +120,7 @@ testimony record [-out sessions] [-app NAME] [-participant P1] [-commit HASH] | `-demo` | off | also serve the instrumented demo app into the same session directory | | `-addr` | `:8737` | demo server listen address (with `-demo`) | -Behaviour: creates a new session directory named after the current time (`YYYY-MM-DD_HHMMSS`) under the `-out` root and writes `manifest.json` (app, participant, tasks, commit, `t0_epoch_ms` set to now) through the same code path as `demo`. On macOS it captures the default microphone to `audio.wav` (16 kHz mono PCM, the canonical ASR input — no re-conversion needed downstream) and, with `-video`, the screen to `screen.mp4`. Audio-only is the default; `-video` opts in. With `-demo` it also serves the demo app so one command captures voice and clicks into the same directory. +Behaviour: creates a new session directory named after the current time (`YYYY-MM-DD_HHMMSS`) under the `-out` root and writes `manifest.json` (app, participant, tasks, commit, `t0_epoch_ms` set to now) through the same code path as `demo`. On macOS it captures the default microphone to `audio.wav` (16 kHz mono PCM, the canonical ASR input — no re-conversion needed downstream) and, with `-video`, the screen to `screen.mp4`; both recorders need ffmpeg on `PATH`. Audio-only is the default; `-video` opts in. With `-demo` it also serves the demo app so one command captures voice and clicks into the same directory. The command blocks until interrupted (`Ctrl+C`). On SIGINT/SIGTERM — and on SIGHUP, so closing the terminal window mid-session finalises exactly like Ctrl+C — it sends each recorder an interrupt so it finalises its container, waits up to five seconds, and hard-kills only on timeout. It then validates each recorder's artefact — `audio.wav`, and `screen.mp4` with `-video` — and prints the exact next commands with the real session directory: with a usable `audio.wav` in place it offers `transcribe` → `merge` → `report` without `-audio`, because the recording is already present. diff --git a/docs/reference/session-directory.md b/docs/reference/session-directory.md index 879322e..1ef5bd0 100644 --- a/docs/reference/session-directory.md +++ b/docs/reference/session-directory.md @@ -154,6 +154,6 @@ A finding's effective status starts `unverified`; verdict records apply in file Human-readable Markdown rendered from the timeline and findings: -- a header with session name, app, participant, duration (`MM:SS`, from the last entry), and utterance/event counts, plus the task list; +- a header with session name, app, participant, duration (`MM:SS`, the latest moment on the timeline — the end (`t1`) of the last utterance when speech closes the session, else the latest entry time), and utterance/event counts, plus the task list; - a **Timeline** section: each utterance as `**[MM:SS] :** ""`, with the events joined to it (within the report's join window) as indented bullets `[MM:SS] "" value="…" ()`; events matched by no utterance appear as standalone bullets in time order; - a **Findings** section rendering `findings.jsonl` grouped by effective status (Confirmed, Unverified, Duplicate, Rejected), each group headed with a count and each finding line carrying its id, type, severity, clock, quote, anchor, and any verdict and date. When there is no `findings.jsonl` the section is a short notice pointing at `analyze` and `review`. diff --git a/internal/demo/demo.go b/internal/demo/demo.go index dd42b55..5552392 100644 --- a/internal/demo/demo.go +++ b/internal/demo/demo.go @@ -367,8 +367,8 @@ func tooLongForJSONL(line []byte) bool { // allowWrite guards the capture write endpoints against cross-origin forgery // (CSRF) and DNS-rebinding of the loopback server. It requires a loopback Host -// (a rebinding page still sends the attacker hostname), a same-origin Origin -// when present, and a JSON Content-Type — a non-CORS-safelisted type that forces +// (a rebinding page still sends the attacker hostname), a loopback Origin when +// present (any loopback host, whatever its port), and a JSON Content-Type — a non-CORS-safelisted type that forces // a preflight the server never answers permissively, so a cross-origin no-cors // "simple request" POST cannot reach the write. It writes the error response and // returns false when the request must be refused. From 5780d1f9ca0d7a89c92ca1b905cc624d5883d690 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:24:15 +0000 Subject: [PATCH 14/26] fix: refuse a reused utterance id at the transcript boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BuildEntries copies each utterance's id verbatim, so a transcript reusing one merged at exit 0 and produced a duplicate-id timeline.jsonl — which analyze then refused while naming the generated file the next merge would recreate, not the transcript the operator can repair. Both adversarial PR reviews flagged the gap (and the false claim in the earlier commit that merge never emits duplicates). checkedUtterances now refuses the reuse, naming both utterances; ids are compared in SafeText form, the collision they later become in analyze's index. Assisted-by: Claude:claude-fable-5 --- internal/timeline/timeline.go | 27 +++++++++++++++++++++++---- internal/timeline/timeline_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/internal/timeline/timeline.go b/internal/timeline/timeline.go index 467178d..4ee18c2 100644 --- a/internal/timeline/timeline.go +++ b/internal/timeline/timeline.go @@ -141,15 +141,17 @@ func BuildEntries(t0EpochMS int64, utts []Utterance, ints []Interaction) []Entry // analyze still indexed its id as citable evidence. Silently omitting an // entry from the human evidence artefact is the outcome the report package's // own +Inf-sentinel fix rules out, so an unrenderable entry refuses loudly, -// naming its 1-based line, rather than reshaping the record. The src value is -// routed through session.SafeText because it is attacker-authorable and the -// message reaches a terminal. +// naming its 1-based entry ordinal — the position among the file's records in +// order, which is the file's line number only when the file carries no blank +// lines (ReadJSONL skips those). The src value is routed through +// session.SafeText because it is attacker-authorable and the message reaches +// a terminal. func CheckSrc(entries []Entry) error { for i, e := range entries { switch e.Src { case "speech", "event": default: - return fmt.Errorf("timeline line %d: unknown src %q (want \"speech\" or \"event\")", i+1, session.SafeText(e.Src)) + return fmt.Errorf("timeline entry %d: unknown src %q (want \"speech\" or \"event\")", i+1, session.SafeText(e.Src)) } } return nil @@ -316,10 +318,27 @@ func CheckInteraction(line []byte, t0EpochMS int64) error { // window would be the disproportionate answer. func checkedUtterances(path string, raw []rawUtterance) ([]Utterance, error) { out := make([]Utterance, 0, len(raw)) + // Refuse a duplicated utterance id at the boundary where the operator can + // repair it. BuildEntries copies each utterance's id verbatim, so a + // transcript reusing one would flow into timeline.jsonl and be refused only + // by analyze — naming a generated file the next merge would recreate, + // instead of the transcript that carries the defect. Ids are compared in + // their session.SafeText form, the form analyze indexes and the emitted + // request shows, so two ids distinct only by stripped bytes count as the + // collision they later become; empty ids are skipped (they cannot be cited, + // and calling two id-less utterances duplicates of "" would misname the + // problem). + seen := map[string]int{} for i, r := range raw { if r.T0 == nil { return nil, fmt.Errorf("%s: utterance %d is missing t0; cannot place it on the session clock", path, i+1) } + if id := session.SafeText(r.ID); id != "" { + if prev, dup := seen[id]; dup { + return nil, fmt.Errorf("%s: utterance %d reuses id %q (first used by utterance %d); ids must be unique for findings to cite them unambiguously", path, i+1, id, prev) + } + seen[id] = i + 1 + } // A present t1 is accepted only when it does not precede t0. An explicit // t1 < t0 is the same inverted-window hazard the nil case is defaulted away // from: EventsNear would join over [t0-window, t1+window] with hi < lo and diff --git a/internal/timeline/timeline_test.go b/internal/timeline/timeline_test.go index 884685a..d7345d5 100644 --- a/internal/timeline/timeline_test.go +++ b/internal/timeline/timeline_test.go @@ -514,3 +514,33 @@ func TestMergeClampsUtteranceInvertedSpan(t *testing.T) { t.Fatalf("want the event to attach to the utterance, got %v", near) } } + +// TestMergeRejectsDuplicateUtteranceID pins the transcript-boundary refusal of +// reused utterance ids. BuildEntries copies each utterance's id verbatim, so a +// transcript reusing one previously merged at exit 0 and produced a +// duplicate-id timeline.jsonl — which analyze then refuses while naming the +// generated file the next merge would recreate, not the transcript that +// carries the defect and that the operator can actually repair. +func TestMergeRejectsDuplicateUtteranceID(t *testing.T) { + dir := t.TempDir() + if err := session.SaveManifest(dir, session.Manifest{Session: "s", T0EpochMS: t0}); err != nil { + t.Fatalf("SaveManifest: %v", err) + } + lines := "" + + `{"id":"utt-001","t0":1.0,"t1":2.0,"speaker":"P1","text":"first"}` + "\n" + + `{"id":"utt-001","t0":40.0,"t1":45.0,"speaker":"P1","text":"second"}` + "\n" + if err := os.WriteFile(filepath.Join(dir, session.TranscriptFile), []byte(lines), 0o644); err != nil { + t.Fatalf("write transcript: %v", err) + } + + _, _, err := Merge(dir) + if err == nil { + t.Fatalf("expected a duplicate-id error, got nil") + } + if !strings.Contains(err.Error(), "utterance 2") || !strings.Contains(err.Error(), `"utt-001"`) { + t.Fatalf("error should name the reusing utterance and the id, got %v", err) + } + if _, statErr := os.Stat(filepath.Join(dir, session.TimelineFile)); statErr == nil { + t.Fatalf("timeline.jsonl was written despite the duplicate id") + } +} From ff7d2cc9e01bfd3728beede216655f87aa53ebec Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:24:15 +0000 Subject: [PATCH 15/26] fix: name timeline refusal positions as entry ordinals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReadJSONL skips blank lines, so the new duplicate-id and unknown-src refusals' 'line N' labels miscounted on exactly the hand-edited files they target — the same a-line-of-no-file defect this branch fixed in WriteJSONL. The messages now say 'entry N', and the comments state that entry ordinals match file lines only for blank-line-free files. Assisted-by: Claude:claude-fable-5 --- internal/analyze/analyze_test.go | 4 ++-- internal/analyze/ingest.go | 20 ++++++++++++-------- internal/report/report.go | 4 ++-- internal/report/report_test.go | 4 ++-- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/internal/analyze/analyze_test.go b/internal/analyze/analyze_test.go index 275652e..1ab599e 100644 --- a/internal/analyze/analyze_test.go +++ b/internal/analyze/analyze_test.go @@ -808,7 +808,7 @@ func TestIngestRefusesUnknownTimelineSrc(t *testing.T) { if err == nil { t.Fatal("Ingest accepted a timeline entry with unknown src") } - if !strings.Contains(err.Error(), "unknown src") || !strings.Contains(err.Error(), "line 2") { - t.Fatalf("error must name the unknown src and its line: %v", err) + if !strings.Contains(err.Error(), "unknown src") || !strings.Contains(err.Error(), "entry 2") { + t.Fatalf("error must name the unknown src and its entry: %v", err) } } diff --git a/internal/analyze/ingest.go b/internal/analyze/ingest.go index f1a2573..77a6e11 100644 --- a/internal/analyze/ingest.go +++ b/internal/analyze/ingest.go @@ -28,13 +28,17 @@ const maxAnswerBytes = 16 << 20 // later survives the index — an honest verbatim quote of the first is rejected, // while a quote of the second validates for a finding anchored at the first's // time, durably pairing a quote with a moment it was never spoken at. Merge -// never emits duplicates (transcribe and BuildEntries synthesise sequential -// ids), so this bites solely on a hand-edited or exchanged timeline.jsonl — -// report deliberately stays positional and keeps rendering such a file. Ids are -// compared in their session.SafeText form, the form the index and the emitted -// request use, so two ids distinct only by stripped bytes count as duplicates -// too. Entries with an empty id are skipped: they cannot be cited, and calling -// two id-less lines "duplicates of \"\"" would misname the actual problem. +// refuses a duplicated utterance id at the transcript boundary (see +// timeline.checkedUtterances) and synthesises sequential event ids, so this +// check bites on a hand-edited or exchanged timeline.jsonl, which reaches this +// reader without passing through merge — report deliberately stays positional +// and keeps rendering such a file. Ids are compared in their session.SafeText +// form, the form the index and the emitted request use, so two ids distinct +// only by stripped bytes count as duplicates too. Entries with an empty id are +// skipped: they cannot be cited, and calling two id-less lines "duplicates of +// \"\"" would misname the actual problem. Positions are 1-based entry +// ordinals, which match file lines only when the file has no blank lines +// (ReadJSONL skips those). func loadTimeline(dir string) ([]timeline.Entry, error) { entries, err := session.ReadJSONL[timeline.Entry](filepath.Join(dir, session.TimelineFile)) if err != nil { @@ -50,7 +54,7 @@ func loadTimeline(dir string) ([]timeline.Entry, error) { continue } if prev, dup := seen[id]; dup { - return nil, fmt.Errorf("timeline id %q appears at lines %d and %d: ids must be unique for evidence to cite them unambiguously", id, prev, i+1) + return nil, fmt.Errorf("timeline id %q appears at entries %d and %d: ids must be unique for evidence to cite them unambiguously", id, prev, i+1) } seen[id] = i + 1 } diff --git a/internal/report/report.go b/internal/report/report.go index 3064bcd..7137ff1 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -41,8 +41,8 @@ func Render(dir string, window float64) (string, error) { // from a hand-edited or exchanged timeline) previously vanished from the // rendered timeline while end() still counted its time into the Duration // header — the report asserted a session span its own timeline never - // reached, and the entry's id stayed citable by findings. Checked before the - // sort so the named line matches the file. + // reached, and the entry's id stayed citable by findings. Checked before + // the sort so the named entry ordinal reflects the file's record order. if err := timeline.CheckSrc(entries); err != nil { return "", err } diff --git a/internal/report/report_test.go b/internal/report/report_test.go index 1fa2498..53f9d74 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -593,7 +593,7 @@ func TestRenderRefusesUnknownSrc(t *testing.T) { if err == nil { t.Fatal("Render silently accepted a timeline entry with unknown src") } - if !strings.Contains(err.Error(), "unknown src") || !strings.Contains(err.Error(), "line 3") { - t.Fatalf("error must name the unknown src and its line: %v", err) + if !strings.Contains(err.Error(), "unknown src") || !strings.Contains(err.Error(), "entry 3") { + t.Fatalf("error must name the unknown src and its entry: %v", err) } } From 3a4c856013835f0af4835462aaa2d0319080087f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:24:16 +0000 Subject: [PATCH 16/26] fix: match the truncating write's permission semantics in the atomic write WriteFileAtomicNoFollow used os.CreateTemp plus Chmod, which applies perm exactly: the offset sidecar became the one artefact whose mode ignored the process umask, and an operator-tightened 0600 sidecar silently widened to 0644 on rewrite. The temp file now opens O_CREATE|O_EXCL with the effective mode (umask applies, as in a plain open), and an existing regular file's own mode is preserved across the replacement. Assisted-by: Claude:claude-fable-5 --- internal/session/session.go | 41 ++++++++++++++++++++++++-------- internal/session/session_test.go | 23 ++++++++++++++++++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/internal/session/session.go b/internal/session/session.go index 4dd79bb..d933589 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "io" + "io/fs" "os" "path/filepath" "strings" @@ -264,23 +265,43 @@ func WriteFileNoFollow(path string, data []byte, perm os.FileMode) error { // matches WriteFileNoFollow so a hostile session directory cannot even retarget // the name); the temp file is created fresh in the same directory, so the // rename never crosses a filesystem. +// +// Permission semantics match WriteFileNoFollow's open(2) behaviour: the temp +// file is created with perm filtered by the process umask, and an existing +// regular file's own mode is preserved across the replacement (an +// operator-tightened sidecar stays tightened) — which is why the temp file is +// opened O_CREATE|O_EXCL with the effective mode rather than via +// os.CreateTemp, whose fixed 0600 plus an explicit Chmod would bypass the +// umask. A crash between create and rename can leave a dot-prefixed +// "..tmp*" file behind; nothing reads it, and the next successful write +// does not depend on it. func WriteFileAtomicNoFollow(path string, data []byte, perm os.FileMode) error { - if fi, err := os.Lstat(path); err == nil && fi.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("refusing to write %s: it is a symlink", path) + if fi, err := os.Lstat(path); err == nil { + if fi.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("refusing to write %s: it is a symlink", path) + } + if fi.Mode().IsRegular() { + perm = fi.Mode().Perm() + } } - f, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*") - if err != nil { - return err + dir, base := filepath.Dir(path), filepath.Base(path) + var f *os.File + var tmp string + for i := 0; ; i++ { + tmp = filepath.Join(dir, fmt.Sprintf(".%s.tmp%d-%d", base, os.Getpid(), i)) + var err error + f, err = os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, perm) + if err == nil { + break + } + if !errors.Is(err, fs.ErrExist) || i >= 100 { + return err + } } - tmp := f.Name() cleanup := func() { f.Close() os.Remove(tmp) } - if err := f.Chmod(perm); err != nil { - cleanup() - return err - } if _, err := f.Write(data); err != nil { cleanup() return err diff --git a/internal/session/session_test.go b/internal/session/session_test.go index feb67c9..a6e0c91 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -563,3 +563,26 @@ func TestWriteFileAtomicNoFollowReplaces(t *testing.T) { t.Fatalf("temp file left behind: %v", names) } } + +// TestWriteFileAtomicNoFollowPreservesExistingMode pins the permission +// semantics the atomic write shares with a plain truncating open: replacing an +// existing regular file keeps that file's own mode, so an operator-tightened +// artefact (a 0600 sidecar) does not silently widen to the caller's default +// perm on rewrite. +func TestWriteFileAtomicNoFollowPreservesExistingMode(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "f") + if err := os.WriteFile(path, []byte("old\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + if err := WriteFileAtomicNoFollow(path, []byte("new\n"), 0o644); err != nil { + t.Fatalf("WriteFileAtomicNoFollow: %v", err) + } + fi, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if got := fi.Mode().Perm(); got != 0o600 { + t.Fatalf("existing file's mode not preserved: got %o, want 600", got) + } +} From 71cf7ebe3f4ae7355f438000503a84c234c3e29b Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:25:04 +0000 Subject: [PATCH 17/26] fix: verify the release binary before installing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version assertion ran after install(1) had already replaced any previously good binary, so a refusal died with the bad binary in place — unlike the checksum and attestation refusals, which fail before touching the destination. The check now runs on the extracted binary in the download's temp directory; only a binary that runs and names the pinned release is installed. Releases predating the version stamp (v0.1.0 only) report 'testimony dev' and are refused, which the comment records. Assisted-by: Claude:claude-fable-5 --- install.sh | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/install.sh b/install.sh index bf188b4..17a12f7 100644 --- a/install.sh +++ b/install.sh @@ -199,15 +199,18 @@ Refusing to install." say " https://cli.github.com — then re-run this installer." fi - mkdir -p "$INSTALL_DIR" tar -xzf "$tmp/$tarball" -C "$tmp" testimony + # Prove the extracted binary runs and is the release it claims BEFORE it + # is installed, so a refusal cannot clobber a previously good binary: a + # failing command substitution inside say's argument does not trip + # `set -e`, so an unrunnable binary (a wrong-platform asset) previously + # replaced the installed one and printed "Installed: ... ()" at exit 0. + # Releases predating the version stamp (v0.1.0) report "testimony dev" + # and are refused here; every release since prints its own tag. + installed_version="$("$tmp/testimony" version)" || die "the release binary failed to run on this machine (a wrong-platform asset?): $tarball" + [ "$installed_version" = "testimony $VERSION" ] || die "the release binary reports \"$installed_version\", expected \"testimony $VERSION\"; refusing to install it" + mkdir -p "$INSTALL_DIR" install -m 0755 "$tmp/testimony" "$INSTALL_DIR/testimony" - # Prove the installed binary runs and is the release it claims before - # announcing success: a failing command substitution inside say's argument - # does not trip `set -e`, so an unrunnable binary (a wrong-platform asset, - # a noexec mount) previously printed "Installed: ... ()" and exited 0. - installed_version="$("$INSTALL_DIR/testimony" version)" || die "the installed binary failed to run: $INSTALL_DIR/testimony" - [ "$installed_version" = "testimony $VERSION" ] || die "the installed binary reports \"$installed_version\", expected \"testimony $VERSION\"" say "Installed: $INSTALL_DIR/testimony ($installed_version)" case ":$PATH:" in From 006b44f0c522b05aa614570348308d474e029aa5 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:25:27 +0000 Subject: [PATCH 18/26] docs: draw the installer's attestation attempt boundary precisely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round's earlier comment rewrite claimed an unreachable attestation API refuses the install, but the auth pre-check itself needs the API: an authenticated gh on a network that blocks api.github.com fails that check and takes the checksum-fallback branch with a not-authenticated note. Both comment blocks now state the real boundary — cannot-attempt (absent, too old, or authentication not establishable) falls back with a note; a verification attempted and then rejected or failed mid-way refuses, fail closed. Assisted-by: Claude:claude-fable-5 --- install.sh | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/install.sh b/install.sh index 17a12f7..6bccc55 100644 --- a/install.sh +++ b/install.sh @@ -25,13 +25,13 @@ # CLI) is available it ALSO runs `gh attestation verify` against the release # workflow's SLSA build-provenance (authenticity — cryptographic proof the tarball # was built by REPPL/Testimony's own release.yml, the strong anchor). A gh that -# cannot attempt the verification — absent, unauthenticated, or too old to know -# attestations — means the install proceeds on the checksum alone, with a note -# saying so. Any other gh failure refuses the install, fail closed: that covers -# a verification gh performed and rejected, and equally one it could not -# complete (the attestation API unreachable), because an inconclusive -# verification cannot be told apart from one an attacker is blocking. Re-run -# when connectivity returns. +# cannot attempt the verification — absent, too old to know attestations, or +# unable to establish its authentication (which an unreachable API also causes +# at the auth check) — means the install proceeds on the checksum alone, with +# a note saying so. Once the verification is attempted, any failure refuses +# the install, fail closed: a rejection, and equally a verification that +# could not complete, because mid-verification failure cannot be told apart +# from an attacker blocking it. Re-run when connectivity returns. # No per-release hash is pinned in this script: the checksums are fetched from # the release itself and the attestation binds them to the workflow. # Everything installs into user-owned locations by default; sudo is never invoked. @@ -167,12 +167,14 @@ Refusing to install." # command or --signer-workflow fails the same way — treating those as # "attestation FAILED" made a freshly `brew install`ed gh strictly worse # than no gh at all, refusing a tarball whose checksum had just verified. - # Every other failure refuses the install, fail closed — a rejected - # attestation, and equally a verification gh could not complete (network, - # API outage): an inconclusive verification cannot be told apart from one - # an attacker is blocking, and that attacker is exactly the one who could - # also serve a substituted tarball with a matching SHA256SUMS. gh's own - # output is shown instead of being swallowed. + # Once the verification is attempted, every failure refuses the install, + # fail closed — a rejected attestation, and equally a verification that + # fails mid-way (network, API outage): at that point an inconclusive + # answer cannot be told apart from one an attacker is blocking. The auth + # check above draws the attempt boundary: a gh whose authentication + # cannot be established — which an unreachable API also causes — is the + # checksum-fallback case, with a note naming it. gh's own output is shown + # instead of being swallowed. if have gh && ! gh auth status --hostname github.com >/dev/null 2>&1; then say "NOTE: 'gh' is installed but not authenticated — installed on the checksum alone." say " 'gh attestation verify' needs an authenticated gh; run 'gh auth login'" From c2a25ae79c0668da65863e53e43f58a0aa0fcca5 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:25:43 +0000 Subject: [PATCH 19/26] fix: keep capture snippets from surfacing async refusals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fetch fallback added for a refused sendBeacon had no rejection handler, and the enclosing try/catch cannot catch an async rejection — so a refused or oversized keepalive POST surfaced as an unhandled promise rejection, in a snippet whose comment promises capture never breaks the app under test. Both the demo page and the how-to's copyable snippet gain the catch; the how-to's interaction() also gains the non-Element target guard the demo page already has. Assisted-by: Claude:claude-fable-5 --- docs/how-to/instrument-your-own-app.md | 4 +++- internal/demo/assets/index.html | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/how-to/instrument-your-own-app.md b/docs/how-to/instrument-your-own-app.md index 5ad470b..b3e4025 100644 --- a/docs/how-to/instrument-your-own-app.md +++ b/docs/how-to/instrument-your-own-app.md @@ -64,11 +64,13 @@ A minimal capture script, following the same conventions as the demo app — pre ? navigator.sendBeacon(url, new Blob([json], { type: "application/json" })) : false; if (!queued) { - fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: json, keepalive: true }); + fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: json, keepalive: true }) + .catch(function () { /* an async refusal must not surface as an unhandled rejection */ }); } } catch (e) { /* capture must never break the app */ } } function interaction(kind, el, extra) { + if (!(el instanceof Element)) return; // a non-Element target must not throw in a capture listener var payload = { t: Date.now(), kind: kind, diff --git a/internal/demo/assets/index.html b/internal/demo/assets/index.html index 90572d0..4c37f8e 100644 --- a/internal/demo/assets/index.html +++ b/internal/demo/assets/index.html @@ -163,7 +163,8 @@

About

? navigator.sendBeacon(url, new Blob([json], { type: "application/json" })) : false; if (!queued) { - fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: json, keepalive: true }); + fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: json, keepalive: true }) + .catch(function () { /* an async refusal must not surface as an unhandled rejection */ }); } } catch (e) { /* capture must never break the app under test */ } } From faa0593907de666e1bd315c9f4127ee39ad24abd Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:25:43 +0000 Subject: [PATCH 20/26] fix: seed the demo display name as Bob Seeding the field as Alice made the tutorial's step a no-op: it has Alice changing the display name to 'Alice', so a reader following it literally typed nothing and fired no input event, losing the very interaction the tutorial and the bundled sample session record. Bob keeps the seed inside the persona set while leaving Alice's change real. Assisted-by: Claude:claude-fable-5 --- internal/demo/assets/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/demo/assets/index.html b/internal/demo/assets/index.html index 4c37f8e..d32ba6d 100644 --- a/internal/demo/assets/index.html +++ b/internal/demo/assets/index.html @@ -81,7 +81,7 @@

General

Shown on your reports
- +
Sent on Mondays
From 6c4636415d757f56931d9b70729617b446eea3ad Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:25:57 +0000 Subject: [PATCH 21/26] docs: align reference pages, CHANGELOG, and code comments with review Adversarial-review corrections: the new duplicate-id and unknown-src refusals are documented in cli.md and session-directory.md (the pages enumerate every other refusal); both session-end/duration definitions state the maximum-over-entries rule instead of the 'last utterance' paraphrase, which overlapping utterances falsify; the verdict-change command carries its required -session flag; README says 'elsewhere' rather than 'on Linux' (the capture gate is on every non-macOS platform); the CHANGELOG drops the sh/bash one-liner overclaim, marks the two new refusals as Behaviour changes per its own preamble (now pointing at that convention instead of a Breaking section), removes a stray blank line, and describes the installer and recorder-diagnosis fixes precisely; the avSignatures comment states the heuristic's limits on both sides instead of overclaiming device-open precision; offsetSidecarExists names the atomic write it now meets; the allowWrite comment is rewrapped; DECISIONS.md's ADR link no longer points at a directory that does not exist yet. Assisted-by: Claude:claude-fable-5 --- .abcd/work/DECISIONS.md | 2 +- CHANGELOG.md | 40 ++++++++++++++++------------- README.md | 5 ++-- docs/how-to/analyse-a-session.md | 6 ++--- docs/reference/cli.md | 4 +-- docs/reference/session-directory.md | 8 +++--- internal/demo/demo.go | 9 ++++--- internal/record/tcc.go | 9 +++++-- internal/transcribe/transcribe.go | 4 +-- 9 files changed, 49 insertions(+), 38 deletions(-) diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index b4d2c01..3e6f8ed 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2,7 +2,7 @@ Append-only, one line per decision, newest last. Date-prefixed. Architecture-shaping decisions graduate to an ADR under -[`../development/decisions/adrs/`](../development/decisions/adrs/). +`.abcd/development/decisions/adrs/` (created with the first ADR). - 2026-07-17 — Adopt the three-tier working-state layout (`.abcd/development/` durable, `.abcd/work/` shared, `.abcd/.work.local/` local-only) and the diff --git a/CHANGELOG.md b/CHANGELOG.md index fc25bcb..eea5bdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,8 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a leading `v`. Before v1.0.0, minor releases may make breaking changes; a change that can -break an existing invocation is called out where it is recorded (to date, -under **Changed**). +break an existing invocation is called out as **Behaviour:** where it is +recorded. ## [Unreleased] @@ -15,13 +15,16 @@ under **Changed**). Evidence integrity: -- `analyze` refuses a timeline carrying duplicate entry ids: of two - utterances sharing an id, only the later one reached the quote validator, - so an honest verbatim quote of the first was rejected while a quote of the - second validated for a finding anchored at the first one's time. `report`, - whose join is positional, still renders such a file. -- `report` and `analyze` refuse a timeline entry whose `src` is neither - `speech` nor `event`, instead of dropping it from the rendered timeline +- **Behaviour:** `merge` refuses a transcript that reuses an utterance id, + and `analyze` refuses a timeline carrying duplicate entry ids — inputs + earlier versions accepted. Of two utterances sharing an id, only the later + one reached the quote validator, so an honest verbatim quote of the first + was rejected while a quote of the second validated for a finding anchored + at the first one's time. `report`, whose join is positional, still renders + such a timeline. +- **Behaviour:** `report` and `analyze` refuse a timeline entry whose `src` + is neither `speech` nor `event` — previously accepted at exit 0 — instead + of dropping it from the rendered timeline while counting its time into the header duration and keeping its id citable as evidence. - The audio offset sidecar is written atomically (temp file and rename): a @@ -32,7 +35,7 @@ Evidence integrity: Capture and diagnostics: - A recorder start-up failure is headlined as a likely permissions issue - only when the ffmpeg output carries a device-open failure; the module + only when the ffmpeg output carries a device-failure signature; the module banner alone — printed on every successful open — used to route a full disk or a missing encoder to the System Settings pane. - `demo -addr :0` prints the actually-bound address instead of the @@ -52,13 +55,15 @@ Checks and installer: satisfiable from the transcript and findings alone, so a regression that dropped all events kept the gate green. - The release workflow downloads a published tarball and requires the - binary's own version output to name the tag; the checks parse `install.sh` - with both shells the documented one-liner pipes it into. -- `install.sh` verifies the installed binary runs and reports the pinned - release before announcing success; an unrunnable binary (a wrong-platform - asset, a `noexec` mount) used to print a success line at exit 0. Its - trust-model comments state the fail-closed attestation behaviour the - implementation has always had. + binary's own version output to name the tag; the checks parse `install.sh` with + `sh -n` and `bash -n`. +- `install.sh` verifies the release binary runs and reports the pinned + release before installing it; an unrunnable binary (a wrong-platform + asset) used to replace any previously installed one and print a success + line at exit 0. Its trust-model comments now state precisely which + attestation failures refuse the install (a verification attempted and + rejected, or failed mid-way) and which fall back to the checksum with a + note (a gh that cannot attempt it). Documentation: @@ -89,7 +94,6 @@ Invocation contract: workflow now gates the pin against the tag so it cannot go stale again. The installer also renders single-option prompts correctly and names the actual cause when a release download fails. - - A stray positional argument is refused as a usage error; it used to be silently swallowed together with every flag after it, so the command ran with defaults at exit 0. diff --git a/README.md b/README.md index a8aac4d..acc2fcd 100644 --- a/README.md +++ b/README.md @@ -57,8 +57,9 @@ open examples/sample-session/report.md Then capture a real one: `testimony record -demo` starts a capture session — recording your voice and clicks in one command — and prints every step. Voice -and screen capture need macOS; on Linux, `record` skips those streams and says -so, and an external recording joins the session via `transcribe -audio`. The +and screen capture need macOS; elsewhere, `record` skips those streams and +says so, and an external recording joins the session via `transcribe -audio`. +The [getting-started tutorial](docs/tutorials/getting-started.md) walks the whole path — record, think aloud, transcribe, merge, report — in about five minutes. The result interleaves speech with interface events: diff --git a/docs/how-to/analyse-a-session.md b/docs/how-to/analyse-a-session.md index bca02d3..301bf21 100644 --- a/docs/how-to/analyse-a-session.md +++ b/docs/how-to/analyse-a-session.md @@ -105,9 +105,9 @@ open sessions//report.md The Findings section lists findings under **Confirmed**, **Unverified**, **Duplicate**, and **Rejected**, each with its quote, anchor, and — where you recorded one — the verdict and its date. Change a verdict at any time with -`testimony review -finding F-NNN -verdict ` (the interactive walk -offers only findings that are still unverified); the latest one wins, and the -history is kept. +`testimony review -session sessions/ -finding F-NNN -verdict ` +(the interactive walk offers only findings that are still unverified); the +latest one wins, and the history is kept. For the exact field rules, see the [session directory reference](../reference/session-directory.md#findingsjsonl); diff --git a/docs/reference/cli.md b/docs/reference/cli.md index cb2e8e3..ca39ecb 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -97,7 +97,7 @@ testimony report -session DIR [-window 2.5] | `-session` | *(required)* | session directory | | `-window` | `2.5` | utterance-to-event join window, in seconds | -Behaviour: reads `manifest.json` (required) and `timeline.jsonl`, plus `findings.jsonl` when present (the Findings section; without the file it is a short notice pointing at `analyze` and `review`). Attaches each event to the first utterance whose span, widened by the window on both sides, contains it; events matched by no utterance appear as standalone lines. Writes `report.md` into the session directory and prints `wrote `. +Behaviour: reads `manifest.json` (required) and `timeline.jsonl`, plus `findings.jsonl` when present (the Findings section; without the file it is a short notice pointing at `analyze` and `review`). A timeline entry whose `src` is neither `speech` nor `event` is refused rather than silently omitted from the rendered record. Attaches each event to the first utterance whose span, widened by the window on both sides, contains it; events matched by no utterance appear as standalone lines. Writes `report.md` into the session directory and prints `wrote `. ## `testimony record` @@ -141,7 +141,7 @@ testimony analyze -session DIR -ingest FILE # validate the answer → find | `-out` | *(stdout)* | emit mode: write the request to `FILE` instead of stdout | | `-ingest` | *(off)* | ingest mode: validate the answer JSON at `FILE` (or `-` for stdin) into `findings.jsonl` | -`analyze` runs in exactly one mode: emit (no `-ingest`) or ingest (`-ingest`). Combining `-out` and `-ingest` is an error. Emit reads `manifest.json` and `timeline.jsonl`; ingest reads `timeline.jsonl` only. Both hint to run `merge` first when the timeline is missing. +`analyze` runs in exactly one mode: emit (no `-ingest`) or ingest (`-ingest`). Combining `-out` and `-ingest` is an error. Emit reads `manifest.json` and `timeline.jsonl`; ingest reads `timeline.jsonl` only. Both hint to run `merge` first when the timeline is missing, and both refuse a timeline whose entries carry a `src` other than `speech` or `event`, or a duplicated entry id — findings cite evidence by id, so a reused one cannot be resolved unambiguously (a `merge`-produced timeline never carries either defect: `merge` refuses a transcript that reuses an utterance id and synthesises event ids itself). Emit behaviour: writes a single self-contained prompt — the rubric version header (`testimony-analysis/v1`), the second-coder stance, two-pass instructions (segment coding, then session synthesis), the rubric body (five `type` definitions, the `1..4` severity scale, the evidence hard-constraints), the session context (app, participant, tasks), the timeline lines inline, and the required output shape with a worked example. Nothing in the session directory is mutated. The timeline is emitted whole (v1 does not chunk by task boundary; the manifest carries no task timestamps). With `-out FILE` the prompt goes to a file and the command prints `wrote `; otherwise it prints to stdout. diff --git a/docs/reference/session-directory.md b/docs/reference/session-directory.md index 1ef5bd0..fdc714a 100644 --- a/docs/reference/session-directory.md +++ b/docs/reference/session-directory.md @@ -65,7 +65,7 @@ One utterance per line. Times are session-relative seconds (audio time plus the | Field | Type | Required | Meaning | |---|---|---|---| -| `id` | string | yes | sequential utterance ID: `utt-001`, `utt-002`, … | +| `id` | string | yes | sequential utterance ID: `utt-001`, `utt-002`, …; unique within the file (`merge` refuses a reused id, since findings cite evidence by id) | | `t0` | number | yes | utterance start, session-relative seconds | | `t1` | number | yes | utterance end, session-relative seconds | | `speaker` | string | no | speaker label; `"P1"` when the engine supplies no diarisation | @@ -96,7 +96,7 @@ The merged record — one entry per line, speech and interface events on the sha | Field | Type | Meaning | |---|---|---| | `t` | number | entry time, session-relative seconds | -| `src` | string | `"speech"` or `"event"` | +| `src` | string | `"speech"` or `"event"` (a closed set: `report` and `analyze` refuse any other value); entry ids must be unique for `analyze`, which resolves cited evidence by id | | `id` | string | `utt-NNN` (from the transcript) or `ev-NNN` (assigned at merge, in input order) | | `payload` | object | source-dependent, see below | @@ -120,7 +120,7 @@ Ingest validates every finding against the merged timeline and is the sole valid | Field | Type | Required | Meaning | |---|---|---|---| | `id` | string | yes | `F-NNN`, zero-padded (`^F-\d{3}$`); unique within the file | -| `t` | number | yes | finding time, session-relative seconds; within `[sessionStart, sessionEnd]` (`sessionStart` is `0` unless the timeline holds negative-time utterances from a recording predating `t0`; `sessionEnd` is the latest moment on the timeline — the end (`t1`) of the last utterance when speech closes the session, else the latest entry time) | +| `t` | number | yes | finding time, session-relative seconds; within `[sessionStart, sessionEnd]` (`sessionStart` is `0` unless the timeline holds negative-time utterances from a recording predating `t0`; `sessionEnd` is the latest moment on the timeline — the maximum over all entries, taking an utterance's end (`t1`) and an event's time) | | `type` | string | yes | one of `bug`, `friction`, `inconsistency`, `preference`, `idea` | | `severity` | integer | yes | usability-severity scale `1..4`: cosmetic, minor, major, blocker | | `mode` | string | no | `A` or `B`, default `A`; only Mode A is produced today | @@ -154,6 +154,6 @@ A finding's effective status starts `unverified`; verdict records apply in file Human-readable Markdown rendered from the timeline and findings: -- a header with session name, app, participant, duration (`MM:SS`, the latest moment on the timeline — the end (`t1`) of the last utterance when speech closes the session, else the latest entry time), and utterance/event counts, plus the task list; +- a header with session name, app, participant, duration (`MM:SS`, the latest moment on the timeline — the maximum over all entries, taking an utterance's end `t1` and an event's time), and utterance/event counts, plus the task list; - a **Timeline** section: each utterance as `**[MM:SS] :** ""`, with the events joined to it (within the report's join window) as indented bullets `[MM:SS] "" value="…" ()`; events matched by no utterance appear as standalone bullets in time order; - a **Findings** section rendering `findings.jsonl` grouped by effective status (Confirmed, Unverified, Duplicate, Rejected), each group headed with a count and each finding line carrying its id, type, severity, clock, quote, anchor, and any verdict and date. When there is no `findings.jsonl` the section is a short notice pointing at `analyze` and `review`. diff --git a/internal/demo/demo.go b/internal/demo/demo.go index 5552392..baf4dae 100644 --- a/internal/demo/demo.go +++ b/internal/demo/demo.go @@ -368,10 +368,11 @@ func tooLongForJSONL(line []byte) bool { // allowWrite guards the capture write endpoints against cross-origin forgery // (CSRF) and DNS-rebinding of the loopback server. It requires a loopback Host // (a rebinding page still sends the attacker hostname), a loopback Origin when -// present (any loopback host, whatever its port), and a JSON Content-Type — a non-CORS-safelisted type that forces -// a preflight the server never answers permissively, so a cross-origin no-cors -// "simple request" POST cannot reach the write. It writes the error response and -// returns false when the request must be refused. +// present (any loopback host, whatever its port), and a JSON Content-Type — a +// non-CORS-safelisted type that forces a preflight the server never answers +// permissively, so a cross-origin no-cors "simple request" POST cannot reach +// the write. It writes the error response and returns false when the request +// must be refused. func allowWrite(w http.ResponseWriter, r *http.Request) bool { if !loopbackHost(r.Host) { refuseWrite(w, r, fmt.Sprintf("unexpected Host %q", r.Host), "unexpected Host", http.StatusForbidden) diff --git a/internal/record/tcc.go b/internal/record/tcc.go index f913c8b..f671bfa 100644 --- a/internal/record/tcc.go +++ b/internal/record/tcc.go @@ -10,7 +10,7 @@ import ( // cause — but because a busy or absent device fails the same way, the message // is phrased as "most likely", never asserted. // -// Each signature must indicate a device-open failure on its own. The list once +// Each signature must indicate a device failure on its own. The list once // held the bare module name "avfoundation" and the bare "failed to" — but // ffmpeg prints "Input #0, avfoundation, from ':default':" on every SUCCESSFUL // input open (no -hide_banner is passed), so the module name sits in the @@ -21,7 +21,12 @@ import ( // branch of classifyRecorderExit was unreachable for any failure past the // input dump. The real macOS denial lines stay matched: TCC prints // "[AVFoundation indev @ …] Failed to create AVCaptureDeviceInput" / -// "Failed to open device" and "not authorized to capture video". +// "Failed to open device" and "not authorized to capture video". The list is +// a heuristic on both sides, deliberately: "input/output error" appears in +// real denial tails but is also the generic EIO string, so a genuine disk I/O +// failure at start-up still earns the permissions headline (phrased "most +// likely", with the raw tail appended); and dropping the bare "abort" trades +// away detection of a denial whose only trace is a crash line. var avSignatures = []string{ "input/output error", "not authorized", diff --git a/internal/transcribe/transcribe.go b/internal/transcribe/transcribe.go index 6908fd2..b644f13 100644 --- a/internal/transcribe/transcribe.go +++ b/internal/transcribe/transcribe.go @@ -341,8 +341,8 @@ func writeOffsetSidecar(dir string, offset float64, provenance string) error { // (malformed, oversized) is precisely the one an explicit -offset repairs — the // guidance readOffsetSidecar prints says so — so parsing here would refuse the // repair. os.Lstat, not os.Stat: a symlink planted at the sidecar name must -// count as present, so the rewrite meets session.WriteFileNoFollow's refusal -// rather than being mistaken for absence and skipped. +// count as present, so the rewrite meets session.WriteFileAtomicNoFollow's +// refusal rather than being mistaken for absence and skipped. func offsetSidecarExists(dir string) (bool, error) { if _, err := os.Lstat(filepath.Join(dir, session.AudioOffsetFile)); err != nil { if errors.Is(err, os.ErrNotExist) { From 160d454cd7b24522f0fdac8fd79ec8190a0f47ce Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:37:11 +0000 Subject: [PATCH 22/26] fix: refuse a transcript utterance id colliding with synthesized event ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkedUtterances compares utterances only with each other, so a transcript utterance named into the ev-NNN namespace collided with the event ids BuildEntries synthesises: merge exited 0 with a duplicate-id timeline that analyze then refused, blaming the generated file — the misdirection the transcript-boundary check was added to remove, and a counterexample to the reference's merge-never-emits-duplicates claim. Merge now scans the built entries and refuses the collision, naming the transcript. The reuse message also notes ids are compared with invisible characters stripped. Assisted-by: Claude:claude-fable-5 --- internal/timeline/timeline.go | 21 +++++++++++++++++++- internal/timeline/timeline_test.go | 31 ++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/internal/timeline/timeline.go b/internal/timeline/timeline.go index 4ee18c2..5d6fb9c 100644 --- a/internal/timeline/timeline.go +++ b/internal/timeline/timeline.go @@ -335,7 +335,7 @@ func checkedUtterances(path string, raw []rawUtterance) ([]Utterance, error) { } if id := session.SafeText(r.ID); id != "" { if prev, dup := seen[id]; dup { - return nil, fmt.Errorf("%s: utterance %d reuses id %q (first used by utterance %d); ids must be unique for findings to cite them unambiguously", path, i+1, id, prev) + return nil, fmt.Errorf("%s: utterance %d reuses id %q (first used by utterance %d; ids are compared with invisible characters stripped); ids must be unique for findings to cite them unambiguously", path, i+1, id, prev) } seen[id] = i + 1 } @@ -425,6 +425,25 @@ func Merge(dir string) (speech, events int, err error) { } entries := BuildEntries(t0, utts, ints) + // The last id gate: checkedUtterances refuses an utterance reusing another + // utterance's id, but a transcript utterance named into the ev-NNN + // namespace collides with the event ids BuildEntries has just synthesised + // — producing a duplicate-id timeline from merge itself, which analyze + // then refuses while the repairable defect lives in transcript.jsonl. + // Scanning the built entries closes that cross-namespace case (and any + // future id source), so a merge-produced timeline never carries a + // duplicate id — the guarantee the CLI reference states. + seen := map[string]bool{} + for _, e := range entries { + id := session.SafeText(e.ID) + if id == "" { + continue + } + if seen[id] { + return 0, 0, fmt.Errorf("%s: utterance id %q collides with a synthesized event id; utterance ids must stay outside the ev-NNN namespace", uttsPath, id) + } + seen[id] = true + } if err := session.WriteJSONL(filepath.Join(dir, session.TimelineFile), entries); err != nil { return 0, 0, fmt.Errorf("write timeline: %w", err) } diff --git a/internal/timeline/timeline_test.go b/internal/timeline/timeline_test.go index d7345d5..5dd1ba4 100644 --- a/internal/timeline/timeline_test.go +++ b/internal/timeline/timeline_test.go @@ -544,3 +544,34 @@ func TestMergeRejectsDuplicateUtteranceID(t *testing.T) { t.Fatalf("timeline.jsonl was written despite the duplicate id") } } + +// TestMergeRejectsUtteranceIDInEventNamespace pins the cross-namespace half of +// the duplicate-id gate: checkedUtterances only compares utterances with each +// other, so a transcript utterance named into the ev-NNN namespace previously +// collided with the event ids BuildEntries synthesises — merge exited 0 with a +// duplicate-id timeline that analyze then refused, blaming the generated file. +func TestMergeRejectsUtteranceIDInEventNamespace(t *testing.T) { + dir := t.TempDir() + if err := session.SaveManifest(dir, session.Manifest{Session: "s", T0EpochMS: t0}); err != nil { + t.Fatalf("SaveManifest: %v", err) + } + utt := `{"id":"ev-001","t0":1.0,"t1":2.0,"speaker":"P1","text":"first"}` + "\n" + if err := os.WriteFile(filepath.Join(dir, session.TranscriptFile), []byte(utt), 0o644); err != nil { + t.Fatalf("write transcript: %v", err) + } + ev := `{"t":` + strconv.FormatInt(t0+1500, 10) + `,"kind":"click"}` + "\n" + if err := os.WriteFile(filepath.Join(dir, session.InteractionsFile), []byte(ev), 0o644); err != nil { + t.Fatalf("write interactions: %v", err) + } + + _, _, err := Merge(dir) + if err == nil { + t.Fatalf("expected a namespace-collision error, got nil") + } + if !strings.Contains(err.Error(), `"ev-001"`) || !strings.Contains(err.Error(), session.TranscriptFile) { + t.Fatalf("error should name the colliding id and the transcript, got %v", err) + } + if _, statErr := os.Stat(filepath.Join(dir, session.TimelineFile)); statErr == nil { + t.Fatalf("timeline.jsonl was written despite the collision") + } +} From 5cdea61efcc200862e443e75fb8a49ed3220563c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:37:11 +0000 Subject: [PATCH 23/26] fix: stage the release binary in the install directory before verifying it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifying the extracted binary in the download's temp directory broke the documented curl-pipe-sh path on hosts with a noexec /tmp or TMPDIR: a perfectly good binary failed the probe with exit 126 after checksum and attestation had passed, with a misleading wrong-platform diagnosis. The probe now runs from a staged copy inside the install directory — where the binary must be executable to be of any use — and only the verified copy is renamed onto the final name, so a refusal still leaves any previously installed binary untouched. The staged name is swept by both cleanup traps. Assisted-by: Claude:claude-fable-5 --- install.sh | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/install.sh b/install.sh index 6bccc55..176941d 100644 --- a/install.sh +++ b/install.sh @@ -128,8 +128,8 @@ install_binary() { # the script: a trap that only cleans up returns control after the handler, # so Ctrl+C at a dependency prompt read was swallowed into the safe-default # answer and the run carried on. - trap 'rm -rf ${tmp:+"$tmp"} ${tmp2:+"$tmp2"} ${gnupg:+"$gnupg"} ${uvd:+"$uvd"}' EXIT - trap 'rm -rf ${tmp:+"$tmp"} ${tmp2:+"$tmp2"} ${gnupg:+"$gnupg"} ${uvd:+"$uvd"}; trap - EXIT; exit 130' INT TERM + trap 'rm -rf ${tmp:+"$tmp"} ${tmp2:+"$tmp2"} ${gnupg:+"$gnupg"} ${uvd:+"$uvd"} ${staged:+"$staged"}' EXIT + trap 'rm -rf ${tmp:+"$tmp"} ${tmp2:+"$tmp2"} ${gnupg:+"$gnupg"} ${uvd:+"$uvd"} ${staged:+"$staged"}; trap - EXIT; exit 130' INT TERM say "Downloading $tarball ..." # A bad --version (or a platform the release never published) surfaces from @@ -202,17 +202,24 @@ Refusing to install." fi tar -xzf "$tmp/$tarball" -C "$tmp" testimony - # Prove the extracted binary runs and is the release it claims BEFORE it - # is installed, so a refusal cannot clobber a previously good binary: a - # failing command substitution inside say's argument does not trip - # `set -e`, so an unrunnable binary (a wrong-platform asset) previously - # replaced the installed one and printed "Installed: ... ()" at exit 0. - # Releases predating the version stamp (v0.1.0) report "testimony dev" - # and are refused here; every release since prints its own tag. - installed_version="$("$tmp/testimony" version)" || die "the release binary failed to run on this machine (a wrong-platform asset?): $tarball" - [ "$installed_version" = "testimony $VERSION" ] || die "the release binary reports \"$installed_version\", expected \"testimony $VERSION\"; refusing to install it" + # Prove the binary runs and is the release it claims BEFORE it replaces + # anything: a failing command substitution inside say's argument does not + # trip `set -e`, so an unrunnable binary (a wrong-platform asset) + # previously replaced the installed one and printed "Installed: ... ()" + # at exit 0. The probe runs from a staged copy inside INSTALL_DIR — the + # download's temp directory may sit on a noexec mount (a hardened /tmp, + # a noexec TMPDIR), where executing "$tmp/testimony" fails for a + # perfectly good binary — and only the verified copy is renamed onto the + # final name, so a refusal leaves any previously installed binary + # untouched. Releases predating the version stamp (v0.1.0) report + # "testimony dev" and are refused here; every release since prints its + # own tag. mkdir -p "$INSTALL_DIR" - install -m 0755 "$tmp/testimony" "$INSTALL_DIR/testimony" + staged="$INSTALL_DIR/.testimony.staged.$$" + install -m 0755 "$tmp/testimony" "$staged" + installed_version="$("$staged" version)" || { rm -f "$staged"; die "the release binary failed to run from $INSTALL_DIR (a wrong-platform asset, or a noexec mount?): $tarball"; } + [ "$installed_version" = "testimony $VERSION" ] || { rm -f "$staged"; die "the release binary reports \"$installed_version\", expected \"testimony $VERSION\"; refusing to install it"; } + mv -f "$staged" "$INSTALL_DIR/testimony" say "Installed: $INSTALL_DIR/testimony ($installed_version)" case ":$PATH:" in From c7220ea0682d802f3f1152da68b2b3eeadcacc16 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:37:11 +0000 Subject: [PATCH 24/26] fix: randomize atomic temp names and preserve an existing file's exact mode The predictable pid-counter temp names let anyone who can write the session directory pre-plant all 101 candidates and deterministically block the sidecar write, where os.CreateTemp's random names could not be exhausted; names now carry a crypto/rand suffix. Mode preservation is applied with fchmod on the temp file, which the umask does not filter, so an existing file's mode round-trips exactly instead of tighten-only; the comment states the two deliberate rename-semantics differences (read-only target replaced, possible temp residue on crash). Assisted-by: Claude:claude-fable-5 --- internal/session/session.go | 39 +++++++++++++++++++++++--------- internal/session/session_test.go | 35 ++++++++++++++++------------ 2 files changed, 49 insertions(+), 25 deletions(-) diff --git a/internal/session/session.go b/internal/session/session.go index d933589..922f92a 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -16,6 +16,7 @@ package session import ( "bufio" "bytes" + "crypto/rand" "encoding/json" "errors" "fmt" @@ -266,29 +267,39 @@ func WriteFileNoFollow(path string, data []byte, perm os.FileMode) error { // the name); the temp file is created fresh in the same directory, so the // rename never crosses a filesystem. // -// Permission semantics match WriteFileNoFollow's open(2) behaviour: the temp -// file is created with perm filtered by the process umask, and an existing -// regular file's own mode is preserved across the replacement (an -// operator-tightened sidecar stays tightened) — which is why the temp file is -// opened O_CREATE|O_EXCL with the effective mode rather than via -// os.CreateTemp, whose fixed 0600 plus an explicit Chmod would bypass the -// umask. A crash between create and rename can leave a dot-prefixed -// "..tmp*" file behind; nothing reads it, and the next successful write -// does not depend on it. +// Permission semantics: a NEW file is created with perm filtered by the +// process umask, exactly as WriteFileNoFollow's open(2) would create it; an +// EXISTING regular file's own mode is preserved exactly across the +// replacement (applied with fchmod on the temp file, which the umask does not +// filter), so an operator-tightened sidecar stays tightened and a +// deliberately widened one is not silently narrowed. Two deliberate +// differences from a truncating open, both inherent to rename-into-place: a +// read-only target is replaced (rename consults the directory's permissions, +// not the file's), and a crash between create and rename can leave a +// dot-prefixed "..tmp-*" file behind — nothing reads it, and the next +// successful write does not depend on it. Temp names carry a random suffix so +// an attacker who can write the directory cannot pre-plant the whole name +// space and deterministically block the write (O_EXCL already stops +// write-through). func WriteFileAtomicNoFollow(path string, data []byte, perm os.FileMode) error { + priorPerm, havePrior := os.FileMode(0), false if fi, err := os.Lstat(path); err == nil { if fi.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("refusing to write %s: it is a symlink", path) } if fi.Mode().IsRegular() { - perm = fi.Mode().Perm() + priorPerm, havePrior = fi.Mode().Perm(), true } } dir, base := filepath.Dir(path), filepath.Base(path) var f *os.File var tmp string for i := 0; ; i++ { - tmp = filepath.Join(dir, fmt.Sprintf(".%s.tmp%d-%d", base, os.Getpid(), i)) + var rnd [4]byte + if _, err := rand.Read(rnd[:]); err != nil { + return err + } + tmp = filepath.Join(dir, fmt.Sprintf(".%s.tmp-%x", base, rnd)) var err error f, err = os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, perm) if err == nil { @@ -302,6 +313,12 @@ func WriteFileAtomicNoFollow(path string, data []byte, perm os.FileMode) error { f.Close() os.Remove(tmp) } + if havePrior { + if err := f.Chmod(priorPerm); err != nil { + cleanup() + return err + } + } if _, err := f.Write(data); err != nil { cleanup() return err diff --git a/internal/session/session_test.go b/internal/session/session_test.go index a6e0c91..89d8e94 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -570,19 +570,26 @@ func TestWriteFileAtomicNoFollowReplaces(t *testing.T) { // artefact (a 0600 sidecar) does not silently widen to the caller's default // perm on rewrite. func TestWriteFileAtomicNoFollowPreservesExistingMode(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "f") - if err := os.WriteFile(path, []byte("old\n"), 0o600); err != nil { - t.Fatalf("seed: %v", err) - } - if err := WriteFileAtomicNoFollow(path, []byte("new\n"), 0o644); err != nil { - t.Fatalf("WriteFileAtomicNoFollow: %v", err) - } - fi, err := os.Stat(path) - if err != nil { - t.Fatalf("stat: %v", err) - } - if got := fi.Mode().Perm(); got != 0o600 { - t.Fatalf("existing file's mode not preserved: got %o, want 600", got) + // Both a tightened and a widened mode round-trip exactly: preservation is + // applied with fchmod on the temp file, which the umask does not filter. + for _, mode := range []os.FileMode{0o600, 0o664} { + dir := t.TempDir() + path := filepath.Join(dir, "f") + if err := os.WriteFile(path, []byte("old\n"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + if err := os.Chmod(path, mode); err != nil { + t.Fatalf("chmod: %v", err) + } + if err := WriteFileAtomicNoFollow(path, []byte("new\n"), 0o644); err != nil { + t.Fatalf("WriteFileAtomicNoFollow: %v", err) + } + fi, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if got := fi.Mode().Perm(); got != mode { + t.Fatalf("existing file's mode not preserved: got %o, want %o", got, mode) + } } } From 7bc86a5250333c739b41fbd50e1319adb3f90f36 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:37:11 +0000 Subject: [PATCH 25/26] docs: close the second review round's wording findings The CLI reference's merge-never-carries-either-defect claim is restated over the now-true guarantee (utterance ids repeating or colliding with synthesized event ids both refuse); the ingest comment names both merge gates; the CHANGELOG preamble drops the Behaviour-marker convention claim its own history does not keep uniformly, the merge bullet covers the namespace collision, and the ragged bullet is refilled; the README paragraph is rewrapped; the how-to snippet gets the demo page's own labelFor (guarded, posting with empty selector and label rather than dropping the record), matching the same-conventions claim. Assisted-by: Claude:claude-fable-5 --- CHANGELOG.md | 14 ++++++-------- README.md | 6 +++--- docs/how-to/instrument-your-own-app.md | 8 ++++++-- docs/reference/cli.md | 2 +- internal/analyze/ingest.go | 5 +++-- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eea5bdd..a20d625 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,7 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a leading `v`. Before v1.0.0, minor releases may make breaking changes; a change that can -break an existing invocation is called out as **Behaviour:** where it is -recorded. +break an existing invocation is called out in the entry that records it. ## [Unreleased] @@ -15,18 +14,17 @@ recorded. Evidence integrity: -- **Behaviour:** `merge` refuses a transcript that reuses an utterance id, - and `analyze` refuses a timeline carrying duplicate entry ids — inputs - earlier versions accepted. Of two utterances sharing an id, only the later +- **Behaviour:** `merge` refuses a transcript whose utterance ids repeat or + collide with the event ids it synthesises, and `analyze` refuses a timeline + carrying duplicate entry ids — inputs earlier versions accepted. Of two utterances sharing an id, only the later one reached the quote validator, so an honest verbatim quote of the first was rejected while a quote of the second validated for a finding anchored at the first one's time. `report`, whose join is positional, still renders such a timeline. - **Behaviour:** `report` and `analyze` refuse a timeline entry whose `src` is neither `speech` nor `event` — previously accepted at exit 0 — instead - of dropping it from the rendered timeline - while counting its time into the header duration and keeping its id - citable as evidence. + of dropping it from the rendered timeline while counting its time into the + header duration and keeping its id citable as evidence. - The audio offset sidecar is written atomically (temp file and rename): a write failure part-way through used to leave a truncated sidecar behind, blocking every later bare `transcribe` with the prior offset diff --git a/README.md b/README.md index acc2fcd..f48062b 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,9 @@ Then capture a real one: `testimony record -demo` starts a capture session — recording your voice and clicks in one command — and prints every step. Voice and screen capture need macOS; elsewhere, `record` skips those streams and says so, and an external recording joins the session via `transcribe -audio`. -The -[getting-started tutorial](docs/tutorials/getting-started.md) walks the whole -path — record, think aloud, transcribe, merge, report — in about five minutes. The result interleaves speech with interface events: +The [getting-started tutorial](docs/tutorials/getting-started.md) walks the +whole path — record, think aloud, transcribe, merge, report — in about five +minutes. The result interleaves speech with interface events: ``` **[00:22] P1:** “Hm. I clicked save and nothing happened. No message, no diff --git a/docs/how-to/instrument-your-own-app.md b/docs/how-to/instrument-your-own-app.md index b3e4025..094c5dd 100644 --- a/docs/how-to/instrument-your-own-app.md +++ b/docs/how-to/instrument-your-own-app.md @@ -54,6 +54,11 @@ A minimal capture script, following the same conventions as the demo app — pre if (el.id) return el.tagName.toLowerCase() + "#" + el.id; return el.tagName.toLowerCase(); } + function labelFor(el) { + if (!(el instanceof Element)) return ""; + var t = (el.closest("[data-testid]") || el).textContent || ""; + return t.trim().replace(/\s+/g, " ").slice(0, 40); + } function post(url, body) { try { var json = JSON.stringify(body); @@ -70,12 +75,11 @@ A minimal capture script, following the same conventions as the demo app — pre } catch (e) { /* capture must never break the app */ } } function interaction(kind, el, extra) { - if (!(el instanceof Element)) return; // a non-Element target must not throw in a capture listener var payload = { t: Date.now(), kind: kind, selector: selectorFor(el), - text: ((el.closest("[data-testid]") || el).textContent || "").trim().replace(/\s+/g, " ").slice(0, 40), + text: labelFor(el), route: location.hash || location.pathname }; if (extra) for (var k in extra) payload[k] = extra[k]; diff --git a/docs/reference/cli.md b/docs/reference/cli.md index ca39ecb..221d2fd 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -141,7 +141,7 @@ testimony analyze -session DIR -ingest FILE # validate the answer → find | `-out` | *(stdout)* | emit mode: write the request to `FILE` instead of stdout | | `-ingest` | *(off)* | ingest mode: validate the answer JSON at `FILE` (or `-` for stdin) into `findings.jsonl` | -`analyze` runs in exactly one mode: emit (no `-ingest`) or ingest (`-ingest`). Combining `-out` and `-ingest` is an error. Emit reads `manifest.json` and `timeline.jsonl`; ingest reads `timeline.jsonl` only. Both hint to run `merge` first when the timeline is missing, and both refuse a timeline whose entries carry a `src` other than `speech` or `event`, or a duplicated entry id — findings cite evidence by id, so a reused one cannot be resolved unambiguously (a `merge`-produced timeline never carries either defect: `merge` refuses a transcript that reuses an utterance id and synthesises event ids itself). +`analyze` runs in exactly one mode: emit (no `-ingest`) or ingest (`-ingest`). Combining `-out` and `-ingest` is an error. Emit reads `manifest.json` and `timeline.jsonl`; ingest reads `timeline.jsonl` only. Both hint to run `merge` first when the timeline is missing, and both refuse a timeline whose entries carry a `src` other than `speech` or `event`, or a duplicated entry id — findings cite evidence by id, so a reused one cannot be resolved unambiguously (a `merge`-produced timeline never carries either defect: `merge` refuses a transcript whose utterance ids repeat or collide with the `ev-NNN` event ids it synthesises). Emit behaviour: writes a single self-contained prompt — the rubric version header (`testimony-analysis/v1`), the second-coder stance, two-pass instructions (segment coding, then session synthesis), the rubric body (five `type` definitions, the `1..4` severity scale, the evidence hard-constraints), the session context (app, participant, tasks), the timeline lines inline, and the required output shape with a worked example. Nothing in the session directory is mutated. The timeline is emitted whole (v1 does not chunk by task boundary; the manifest carries no task timestamps). With `-out FILE` the prompt goes to a file and the command prints `wrote `; otherwise it prints to stdout. diff --git a/internal/analyze/ingest.go b/internal/analyze/ingest.go index 77a6e11..c2f8895 100644 --- a/internal/analyze/ingest.go +++ b/internal/analyze/ingest.go @@ -28,8 +28,9 @@ const maxAnswerBytes = 16 << 20 // later survives the index — an honest verbatim quote of the first is rejected, // while a quote of the second validates for a finding anchored at the first's // time, durably pairing a quote with a moment it was never spoken at. Merge -// refuses a duplicated utterance id at the transcript boundary (see -// timeline.checkedUtterances) and synthesises sequential event ids, so this +// refuses a duplicated utterance id at the transcript boundary and an +// utterance id colliding with the event ids it synthesises (see +// timeline.checkedUtterances and the id scan in timeline.Merge), so this // check bites on a hand-edited or exchanged timeline.jsonl, which reaches this // reader without passing through merge — report deliberately stays positional // and keeps rendering such a file. Ids are compared in their session.SafeText From cffa21617f6323750f23c589f662dd4d11327ac1 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:42:26 +0000 Subject: [PATCH 26/26] docs: state the event-id namespace rule in the transcript table Both final reviews noted the transcript id row documented only same-file uniqueness while merge now also refuses a collision with the synthesized ev-NNN event ids, and the refilled CHANGELOG bullet kept one over-long line; the row states the full rule and the bullet wraps with its siblings. Assisted-by: Claude:claude-fable-5 --- CHANGELOG.md | 10 +++++----- docs/reference/session-directory.md | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a20d625..86bfb91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,11 @@ Evidence integrity: - **Behaviour:** `merge` refuses a transcript whose utterance ids repeat or collide with the event ids it synthesises, and `analyze` refuses a timeline - carrying duplicate entry ids — inputs earlier versions accepted. Of two utterances sharing an id, only the later - one reached the quote validator, so an honest verbatim quote of the first - was rejected while a quote of the second validated for a finding anchored - at the first one's time. `report`, whose join is positional, still renders - such a timeline. + carrying duplicate entry ids — inputs earlier versions accepted. Of two + utterances sharing an id, only the later one reached the quote validator, + so an honest verbatim quote of the first was rejected while a quote of the + second validated for a finding anchored at the first one's time. `report`, + whose join is positional, still renders such a timeline. - **Behaviour:** `report` and `analyze` refuse a timeline entry whose `src` is neither `speech` nor `event` — previously accepted at exit 0 — instead of dropping it from the rendered timeline while counting its time into the diff --git a/docs/reference/session-directory.md b/docs/reference/session-directory.md index fdc714a..df9d32d 100644 --- a/docs/reference/session-directory.md +++ b/docs/reference/session-directory.md @@ -65,7 +65,7 @@ One utterance per line. Times are session-relative seconds (audio time plus the | Field | Type | Required | Meaning | |---|---|---|---| -| `id` | string | yes | sequential utterance ID: `utt-001`, `utt-002`, …; unique within the file (`merge` refuses a reused id, since findings cite evidence by id) | +| `id` | string | yes | sequential utterance ID: `utt-001`, `utt-002`, …; unique within the file and outside the `ev-NNN` namespace `merge` synthesises for events (`merge` refuses a reused or colliding id, since findings cite evidence by id) | | `t0` | number | yes | utterance start, session-relative seconds | | `t1` | number | yes | utterance end, session-relative seconds | | `speaker` | string | no | speaker label; `"P1"` when the engine supplies no diarisation |