Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,64 @@ called out in a **Breaking** section.

## [Unreleased]

## [0.3.0] - 2026-07-22

A robustness release. No new commands: the pipeline gains no surface, but the
existing one no longer accepts malformed or hostile session input in silence.
Every fix below carries a regression test, and the changes were confirmed by a
multi-round review that finished with two consecutive passes finding nothing.

### Fixed

Evidence-record integrity — the report is the artefact of record, so a wrong
number in it is the worst kind of bug:

- A transcript with missing or duplicate utterance `id`s no longer renders every
event under every utterance. Events attach by position, not by an unvalidated
id.
- A finding, utterance, interaction, or ASR segment that omits its required time
is now rejected rather than silently anchored at the session start (`[00:00]`).
Absent and a genuine zero are distinguished throughout.
- A manifest whose `t0_epoch_ms` is absent or negative is refused wherever a time
is placed on the session clock — `merge` and `transcribe` alike — instead of
shifting every event by roughly the whole Unix epoch and reporting success.
- Pre-`t0` times (a recording that predates the manifest anchor) render with a
signed clock rather than being clamped to `[00:00]`.

Robustness against malformed or exchanged sessions — a session directory is an
exchange unit and may be attacker-authored:

- A symlink or FIFO planted at any session artefact is refused on both the read
and the write path, rather than redirecting a write or hanging the CLI in
`open(2)` for ever.
- No writer emits a JSONL line larger than the readers can take back; an
over-long record is refused at the point of capture and of write, not
discovered as a permanently unreadable session later.
- `analyze` no longer truncates a findings file on an empty answer, and no longer
loses a human verdict whose value falls outside the known set.
- Untrusted manifest, transcript, and finding text can no longer inject terminal
escape sequences or forge document structure; the sanitiser now covers the
complete Unicode Bidi_Control set.

Resource and process lifecycle:

- `record` and the capture server shut down under a deadline, so a stalled
connection can no longer hang session finalisation after Ctrl+C.
- A partial write (a full disk) leaves no truncated, unreadable line behind in
any append path.
- `record` classifies a start-up permission denial correctly instead of
misreporting it as an unexpected mid-session stop.
- An empty capture address no longer binds the unauthenticated endpoints on
every network interface.

### Changed

- Validation is stricter at the pipeline's boundaries. Inputs that violate the
documented session schema — a missing required time, an absent or negative
anchor, an over-long record — are now refused with a clear error where earlier
versions accepted them and produced a silently wrong artefact. Well-formed
sessions, including the bundled sample, are unaffected.

## [0.2.0] - 2026-07-18

### Added
Expand Down
7 changes: 5 additions & 2 deletions internal/analyze/analyze.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"

Expand Down Expand Up @@ -84,7 +83,11 @@ func IsFindingID(s string) bool { return findingIDRe.MatchString(s) }
// callers can render an absence notice.
func Load(dir string) ([]Finding, []Verdict, error) {
path := filepath.Join(dir, session.FindingsFile)
f, err := os.Open(path)
// Route through the read-side no-follow guard, not plain os.Open: findings.jsonl
// in an exchanged (attacker-authored) session may be a symlink or a FIFO, and a
// FIFO would block this open in open(2) for ever. A missing file still returns
// an fs.ErrNotExist-satisfying error, which callers render as an absence notice.
f, err := session.OpenFileNoFollowRead(path)
if err != nil {
return nil, nil, err
}
Expand Down
112 changes: 112 additions & 0 deletions internal/analyze/analyze_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package analyze

import (
"fmt"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -433,3 +434,114 @@ func TestIngestRejectsOversizedEvidence(t *testing.T) {
t.Fatalf("findings.jsonl was written despite the oversized evidence array")
}
}

// TestIngestRejectsFindingWithoutT is the missing-anchor regression: a finding
// that omits "t" must be refused, not filed at the start of the session. Pre-fix
// Finding.T decoded as a value type, so an absent "t" became 0 — indistinguishable
// from a finding genuinely anchored at t=0 — and validate's only check on t
// (the range [0, end] for a normal session) waved it through. Ingest returned no
// error and the finding was written and rendered at [00:00], tens of seconds from
// the utterance at t=22 it quotes.
func TestIngestRejectsFindingWithoutT(t *testing.T) {
dir := writeSession(t, timelineFixture)
answer := `{"rubric":"testimony-analysis/v1","findings":[
{"id":"F-001","type":"bug","severity":3,"quote":"I clicked save and nothing happened",
"evidence":["utt-004","ev-003"]}
]}`
_, err := Ingest(dir, strings.NewReader(answer))
if err == nil || !strings.Contains(err.Error(), "missing t") {
t.Fatalf("expected a missing-t refusal, got %v", err)
}
if _, statErr := os.Stat(filepath.Join(dir, session.FindingsFile)); statErr == nil {
t.Fatalf("findings.jsonl was written despite the finding having no t")
}
}

// TestIngestAcceptsFindingAtZero is the other half of the missing-t fix: t=0 is
// a legitimate anchor (a finding at the very start of the session), which is why
// the check is a nil-pointer test and not a zero test. A zero test would reject
// this honest finding.
func TestIngestAcceptsFindingAtZero(t *testing.T) {
// utt-004 is moved to t=0 so a finding anchored there is genuinely in range.
zeroTimeline := `{"t":0,"src":"speech","id":"utt-004","payload":{"speaker":"P1","t1":6,"text":"Hm. I clicked save and nothing happened. No message."}}
`
dir := writeSession(t, zeroTimeline)
answer := `{"rubric":"testimony-analysis/v1","findings":[
{"id":"F-001","t":0,"type":"bug","severity":3,"quote":"I clicked save and nothing happened",
"evidence":["utt-004"]}
]}`
findings, err := Ingest(dir, strings.NewReader(answer))
if err != nil {
t.Fatalf("Ingest of a finding anchored at t=0: %v", err)
}
if len(findings) != 1 || findings[0].T != 0 {
t.Fatalf("got %+v, want one finding at t=0", findings)
}
}

// longIDTimeline returns a timeline holding one utterance whose id is ~2 MiB
// long, and that id. Every line stays under session.MaxJSONLLine, so the
// timeline itself is readable; a finding citing the id a few times nonetheless
// serialises past the limit. Long ids are the way to build such a finding
// without an unreadable fixture: a huge quote cannot do it, because the quote
// must be a verbatim substring of the cited utterance and so would push that
// utterance's own line over the limit first.
func longIDTimeline(t float64, id string) string {
return fmt.Sprintf(
`{"t":%g,"src":"speech","id":%q,"payload":{"speaker":"P1","t1":%g,"text":"I clicked save and nothing happened. No message."}}`+"\n",
t, id, t+6)
}

// TestIngestRejectsOversizedFindingLine is the findings-brick regression seen
// through serialised size rather than cardinality: maxEvidence bounds how many
// ids a finding cites, but nothing bounded how long those ids are, so a finding
// whose fields are each individually valid still serialised to a findings.jsonl
// line larger than session.MaxJSONLLine. Pre-fix Ingest wrote it and reported
// success, leaving the file permanently unreadable to report, review, and the
// re-ingest recovery path. Nothing may be written, and the refusal must name the
// offending finding and its size.
func TestIngestRejectsOversizedFindingLine(t *testing.T) {
longID := "utt-" + strings.Repeat("x", 2<<20)
dir := writeSession(t, longIDTimeline(22, longID))

answer := fmt.Sprintf(
`{"findings":[{"id":"F-001","t":22,"type":"bug","severity":3,"quote":"I clicked save and nothing happened","evidence":[%q,%q,%q]}]}`,
longID, longID, longID)
_, err := Ingest(dir, strings.NewReader(answer))
if err == nil || !strings.Contains(err.Error(), "exceeding the") {
t.Fatalf("expected an over-long line refusal, got %v", err)
}
if !strings.Contains(err.Error(), "F-001") {
t.Fatalf("refusal does not name the offending finding:\n%v", err)
}
if _, statErr := os.Stat(filepath.Join(dir, session.FindingsFile)); statErr == nil {
t.Fatalf("findings.jsonl was written despite an over-long finding line")
}
}

// TestIngestOversizedFindingLeavesPriorFileIntact proves the size check is
// transactional: it runs before any write, so an answer whose second finding is
// over-long leaves a prior good findings.jsonl exactly as it was, rather than
// truncating it and then failing.
func TestIngestOversizedFindingLeavesPriorFileIntact(t *testing.T) {
longID := "utt-" + strings.Repeat("x", 2<<20)
dir := writeSession(t, timelineFixture+longIDTimeline(23, longID))

if _, err := Ingest(dir, strings.NewReader(goodAnswer)); err != nil {
t.Fatalf("first Ingest: %v", err)
}
path := filepath.Join(dir, session.FindingsFile)
before, _ := os.ReadFile(path)

answer := fmt.Sprintf(`{"findings":[
{"id":"F-001","t":22,"type":"bug","severity":3,"quote":"I clicked save and nothing happened","evidence":["utt-004"]},
{"id":"F-002","t":23,"type":"friction","severity":2,"quote":"No message","evidence":[%q,%q,%q]}
]}`, longID, longID, longID)
if _, err := Ingest(dir, strings.NewReader(answer)); err == nil || !strings.Contains(err.Error(), "F-002") {
t.Fatalf("expected an over-long line refusal naming F-002, got %v", err)
}
after, _ := os.ReadFile(path)
if string(before) != string(after) {
t.Fatalf("a prior findings.jsonl was modified despite the refusal")
}
}
97 changes: 84 additions & 13 deletions internal/analyze/ingest.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,19 @@ func Ingest(dir string, r io.Reader) ([]Finding, error) {
decoded = append(decoded, positioned{finding: f, at: i + 1})
}
errs = append(errs, validate(decoded, idx)...)
if len(errs) > 0 {
return nil, errors.Join(errs...)
}

// The model is never trusted: every finding lands unverified. Laundering the
// status here, before the size check below, is what makes that check measure
// the line actually written rather than the one the answer proposed.
findings := make([]Finding, len(decoded))
for i, p := range decoded {
findings[i] = p.finding
findings[i].Status = "unverified"
}
errs = append(errs, oversizedFindings(findings, decoded)...)

if len(errs) > 0 {
return nil, errors.Join(errs...)
}

if held, err := holdsVerdicts(dir); err != nil {
Expand All @@ -103,16 +109,39 @@ func Ingest(dir string, r io.Reader) ([]Finding, error) {
return nil, fmt.Errorf("refusing to overwrite %s: it already holds verdict records (the retained precision record)", session.FindingsFile)
}

// The model is never trusted: every finding lands unverified.
for i := range findings {
findings[i].Status = "unverified"
}
if err := session.WriteJSONL(filepath.Join(dir, session.FindingsFile), findings); err != nil {
return nil, fmt.Errorf("write findings: %w", err)
}
return findings, nil
}

// oversizedFindings reports any finding whose findings.jsonl line — its JSON
// encoding plus the newline WriteJSONL appends — would exceed
// session.MaxJSONLLine, the shared invariant every reader scans to. maxEvidence
// bounds how many ids a finding may cite, but nothing bounds the length of a
// verbatim quote, so a finding with a perfectly valid evidence array and an
// enormous quote still serialises to a line no reader can take back: report,
// review, and the re-ingest recovery path would all fail on the file Ingest had
// just reported writing successfully. The check therefore runs before any write
// and joins the transactional error set, so an over-long finding leaves the
// previous findings.jsonl untouched rather than bricking it. Labels come from
// each finding's answer position for the same reason validate's do.
func oversizedFindings(findings []Finding, decoded []positioned) []error {
var errs []error
for i, f := range findings {
label := findingLabel(f, decoded[i].at)
line, err := json.Marshal(f)
if err != nil {
errs = append(errs, fmt.Errorf("%s: cannot encode as JSON: %w", label, err))
continue
}
if len(line)+1 > session.MaxJSONLLine {
errs = append(errs, fmt.Errorf("%s: encodes to %d bytes, exceeding the %d-byte %s line limit", label, len(line)+1, session.MaxJSONLLine, session.FindingsFile))
}
}
return errs
}

// parseContainer accepts either a top-level object with a "findings" array (the
// preferred container, optionally carrying a "rubric") or a bare array of
// findings. It returns the raw finding elements and the rubric string (empty
Expand Down Expand Up @@ -146,17 +175,56 @@ func parseContainer(data []byte) ([]json.RawMessage, string, error) {
}
}

// rawFinding is how one element of the untrusted answer is decoded before it is
// trusted. Its T is a pointer so that an absent "t" stays distinguishable from a
// genuine 0: a finding anchored at the very start of the session legitimately
// carries t equal to 0, so a value-typed field cannot tell the two apart. With
// one, an answer omitting "t" decodes to 0 and sails through validate's range
// check — whose floor is 0 for any normal session — and the finding is filed at
// [00:00] by report and review, tens of seconds from the utterance it quotes,
// with nothing on the record to say the model never placed it. Every other
// required field's absence is already caught by its own rule, so t is the lone
// one needing this. Everything else mirrors Finding, which is the shape
// DisallowUnknownFields is closed against.
type rawFinding struct {
ID string `json:"id"`
T *float64 `json:"t"`
Type string `json:"type"`
Severity int `json:"severity"`
Mode string `json:"mode,omitempty"`
Quote string `json:"quote"`
Evidence []string `json:"evidence"`
UI *UI `json:"ui,omitempty"`
Status string `json:"status"`
}

// decodeFinding strictly decodes one finding element. DisallowUnknownFields
// closes the shape: a hallucinated or mistyped field is a hard error rather
// than silently dropped, and it applies to the nested ui object too.
// than silently dropped, and it applies to the nested ui object too. A missing
// "t" is rejected here rather than in validate, because by the time a finding
// reaches validate its unset t is indistinguishable from an honest 0 (see
// rawFinding).
func decodeFinding(raw json.RawMessage) (Finding, error) {
var f Finding
var rf rawFinding
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&f); err != nil {
return f, err
if err := dec.Decode(&rf); err != nil {
return Finding{}, err
}
if rf.T == nil {
return Finding{}, fmt.Errorf("missing t; a finding must name the moment it is anchored to")
}
return f, nil
return Finding{
ID: rf.ID,
T: *rf.T,
Type: rf.Type,
Severity: rf.Severity,
Mode: rf.Mode,
Quote: rf.Quote,
Evidence: rf.Evidence,
UI: rf.UI,
Status: rf.Status,
}, nil
}

// holdsVerdicts reports whether an existing findings.jsonl already contains any
Expand All @@ -168,7 +236,10 @@ func decodeFinding(raw json.RawMessage) (Finding, error) {
// a re-ingest — exactly the precision history the guard exists to protect.
func holdsVerdicts(dir string) (bool, error) {
path := filepath.Join(dir, session.FindingsFile)
f, err := os.Open(path)
// Read-side no-follow guard rather than plain os.Open: a FIFO planted at
// findings.jsonl in an exchanged session would otherwise hang this open for
// ever. A missing file still satisfies os.ErrNotExist and is not an error here.
f, err := session.OpenFileNoFollowRead(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
Expand Down
17 changes: 14 additions & 3 deletions internal/analyze/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,18 @@ func atPositions(findings []Finding) []positioned {
return out
}

// findingLabel names a finding in an error message: its own id when that id is
// well-formed, and otherwise its position in the answer, which is the only
// handle the operator has on a finding whose id is unusable. Both the schema
// rules and the serialised-size check label through here so an operator reading
// a joined error sees one naming scheme throughout.
func findingLabel(f Finding, at int) string {
if IsFindingID(f.ID) {
return f.ID
}
return fmt.Sprintf("finding #%d", at)
}

// validate runs every schema rule against the decoded findings and returns all
// errors (transactional and exhaustive), each naming the finding, the field,
// and the offending value. Positional labels come from each finding's recorded
Expand All @@ -107,9 +119,8 @@ func validate(findings []positioned, idx timelineIndex) []error {

for _, p := range findings {
f := p.finding
label := f.ID
if !IsFindingID(label) {
label = fmt.Sprintf("finding #%d", p.at)
label := findingLabel(f, p.at)
if !IsFindingID(f.ID) {
errs = append(errs, fmt.Errorf("%s: id %q must match ^F-\\d{3}$", label, f.ID))
} else if prev, dup := seen[f.ID]; dup {
errs = append(errs, fmt.Errorf("%s: duplicate id (first seen at finding #%d)", f.ID, prev))
Expand Down
Loading