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 @@