Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f272889
fix: refuse wrong invocations at the usage status across all commands
REPPL Jul 29, 2026
5739716
fix: list record's -commit flag and the help command in the usage text
REPPL Jul 29, 2026
2d70135
fix: refuse capture posts that merge's reader would refuse
REPPL Jul 29, 2026
bcad37a
fix: surface the wider bind's loopback-only capture pin to the operator
REPPL Jul 29, 2026
ee154ee
fix: persist the offset sidecar before the conversion replaces audio.wav
REPPL Jul 29, 2026
095fd2e
fix: finalise artefacts and print next commands on an early recorder …
REPPL Jul 29, 2026
9270623
docs: correct the reference pages against the code
REPPL Jul 29, 2026
75f37f2
docs: close the round's remaining docs-accuracy findings
REPPL Jul 29, 2026
c50c7a8
docs: record the post-v0.4.0 changes in an Unreleased section
REPPL Jul 29, 2026
f6d5d36
fix: correct the installer's failure handling
REPPL Jul 29, 2026
c71f4cf
fix: state --verify-tag's real guarantee and guard the tripwire's env…
REPPL Jul 29, 2026
cef4e3d
chore: record the bughunt-9 round in the changelog and decision log
REPPL Jul 29, 2026
02c0af0
fix: log every refused capture write, and refuse over-length before s…
REPPL Jul 29, 2026
dcb71f0
fix: bound the sidecar rollback capture; keep an uncapturable prior
REPPL Jul 29, 2026
d68afcb
fix: report a single diagnosis when a recorder exits early
REPPL Jul 29, 2026
296a805
fix: refuse stray arguments on version and help too
REPPL Jul 29, 2026
f933444
fix: fail loudly if detectEngine and CheckEngine ever drift
REPPL Jul 29, 2026
30894fa
fix: sweep every temp dir on interrupt and pin the gh auth probe to g…
REPPL Jul 29, 2026
caa1523
docs: state the authenticated-gh condition and the full capture refusals
REPPL Jul 29, 2026
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
13 changes: 13 additions & 0 deletions .abcd/work/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,16 @@ Architecture-shaping decisions graduate to an ADR under
AGENTS.md current state). The /dev/null TTY-gate nitpick is recorded but
unfixed (a true isatty check needs a dependency); the AGENTS.md fence's
dangling brief link is an upstream abcd defect, not fixable here.
- 2026-07-29 — Bug-hunt round 9: four hunters, eight adversarial refuters
(two per dimension), 27 confirmed findings (11 substantive, 16 nitpick),
3 candidates refuted, 1 skipped as recorded in round 8. Headlines: stray
positional arguments no longer silently swallow the flags after them; the
capture endpoint refuses records merge cannot read (shared validator);
the offset sidecar persists before the conversion's rename so no refusal
destroys a record-origin audio.wav; an unauthenticated gh falls back to
the verified checksum instead of a false provenance refusal; optional
dependency failures skip instead of aborting the installer; CHANGELOG
gains its Unreleased section; reference pages corrected against the code
(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.
13 changes: 10 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,10 @@ jobs:
dist/SHA256SUMS

- name: Create the GitHub Release and upload the tarballs + SHA256SUMS
# --verify-tag: the tag must already exist (it triggered this run) and point
# at the checked-out commit. --generate-notes lists the merged PRs since the
# previous tag.
# --verify-tag: the tag must already exist in the remote (it triggered this
# run) — that is all gh checks; the moved-tag protection is the checkout of
# github.sha above, which pins what gets built regardless of where the tag
# points by now. --generate-notes lists the merged PRs since the previous tag.
run: gh release create "$TAG" dist/testimony_*.tar.gz dist/SHA256SUMS --verify-tag --title "$TAG" --generate-notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down Expand Up @@ -258,6 +259,12 @@ jobs:
if: ${{ !cancelled() }}
run: |
set -euo pipefail
# This step runs under !cancelled(), so the env-recording step may have
# failed before writing these; report that instead of dying on set -u.
if [ -z "${DEFAULT_BRANCH_START_SHA:-}" ] || [ -z "${DEFAULT_BRANCH:-}" ]; then
echo "tripwire inconclusive: the default-branch tip was never recorded (an earlier step failed first)."
exit 0
fi
end="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${DEFAULT_BRANCH}" --jq '.object.sha')"
if [ "$end" = "${DEFAULT_BRANCH_START_SHA}" ]; then
echo "default branch unchanged across the release job (${end}); the job pushed nothing."
Expand Down
65 changes: 65 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,70 @@ leading `v`.
Before v1.0.0, minor releases may make breaking changes; each one is
called out in a **Breaking** section.

## [Unreleased]

### Fixed

Invocation contract (from the round-8 bug-hunt):

- Every usage error now exits 2, as the CLI reference documents: a missing
required `-session`, a disallowed flag combination (`analyze -out` with
`-ingest`), and a non-finite `report -window` were reported at the runtime
status 1, indistinguishable to a script from a session that could not be
read.
- `report -window NaN`/`±Inf` is refused instead of silently rendering a
report whose events are detached from (or all filed under) the speech they
accompanied.
- `transcribe -audio` resolves the audio→session offset before the conversion
mutates the session, so a refused run no longer destroys a record-origin
`audio.wav`; an explicit `-offset` now rewrites an existing sidecar.
- `report` renders a hand-ordered (or hand-edited) `timeline.jsonl` in time
order instead of trusting the file's line order.
- `install.sh` installs the current release: its version pin had been left at
`v0.1.0`, handing new users a three-release-old binary, and the release
workflow now gates the pin against the tag so it cannot go stale again. The
installer also renders single-option prompts correctly and names the actual
cause when a release download fails.

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.
- The remaining invalid-flag-value paths exit 2: `review`'s
`-finding`/`-verdict` pairing and verdict syntax, an unknown
`transcribe -engine`, and a malformed capture `-addr` (which also no longer
creates a session directory before refusing).

Capture integrity (round 9):

- `POST /api/interactions` refuses with 400 any record `merge` would refuse —
a non-object body, or a missing/implausible `t` or missing `kind` — instead
of persisting with 204 a line that later fails the whole session's merge.
- A refused capture write and a deliberately wider (non-loopback) bind are
reported on stderr; the page posts via `sendBeacon`, so the terminal is the
only place a refusal can surface.
- The audio offset sidecar is persisted before the conversion's rename
replaces `audio.wav`, so a refused sidecar write leaves the session
byte-for-byte as it was found, and a failed rename rolls the sidecar back.
- 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):

- An unauthenticated (or attestation-incapable) `gh` no longer refuses the
install as a false provenance failure: it falls back to the verified
checksum, exactly like no `gh`; a verification `gh` actually performed and
rejected still refuses, and gh's own message is shown instead of being
swallowed.
- An optional-dependency failure (an unreachable ffmpeg or uv host, a failed
unpack or brew install) skips its step with guidance instead of aborting
the whole installer with the child's raw exit code and a leaked temp
directory; Ctrl+C stops the installer instead of being read as "skip";
`--help` works through the documented pipe invocation; `--dir`/`--version`
without a value are refused cleanly; the whisper.cpp model recipe
downloads into a directory `-model NAME` actually searches.

## [0.4.0] - 2026-07-24

A second robustness pass over the same capture → analysis pipeline, closing the
Expand Down Expand Up @@ -231,6 +295,7 @@ Resource and process lifecycle:
- A one-line installer and a checksummed release of static binaries for macOS
and Linux.

[Unreleased]: https://github.com/REPPL/Testimony/compare/v0.4.0...HEAD
[0.4.0]: https://github.com/REPPL/Testimony/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/REPPL/Testimony/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/REPPL/Testimony/compare/v0.1.0...v0.2.0
Expand Down
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,18 @@ Raw audio and video never leave your machine; only derived text is analysed. See
## Install

One line, no admin rights required (binary goes to `~/.local/bin`; the download
is verified against the release's published checksums and, when the GitHub CLI
is installed, its SLSA build-provenance attestation):
is verified against the release's published checksums and, when an
authenticated GitHub CLI (`gh`) is available, its SLSA build-provenance
attestation):

```sh
curl -fsSL https://raw.githubusercontent.com/REPPL/Testimony/main/install.sh | sh
```

The installer then offers to set up what `transcribe` needs — ffmpeg and a local
ASR engine (WhisperX or whisper.cpp) — via Homebrew where available, or as
user-local installs for machines without admin rights. Prefer to read before you
The installer then offers to set up the pipeline's two dependencies — ffmpeg,
which `record`'s capture (and `transcribe -audio`'s conversion) needs, and a
local ASR engine (WhisperX or whisper.cpp) for `transcribe` — via Homebrew
where available, or as user-local installs for machines without admin rights. Prefer to read before you
run (sensible), or pass flags:

```sh
Expand Down Expand Up @@ -84,7 +86,9 @@ Each session is one folder of small, inspectable files:
```
sessions/<timestamp>/
manifest.json # app, participant, tasks, t0_epoch_ms (the shared clock anchor)
audio.wav # 16 kHz mono extract (local only)
audio.wav # 16 kHz mono ASR input, captured or converted (local only)
audio.offset.json # audio→session offset for an external recording (local only)
screen.mp4 # screen capture, with record -video (local only)
events.rrweb.jsonl # raw rrweb stream (archival)
interactions.jsonl # normalised interaction events
transcript.jsonl # time-aligned utterances
Expand Down
2 changes: 1 addition & 1 deletion docs/explanation/how-alignment-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The voice recording is the awkward stream. A transcription engine reports times
session_time = audio_time + offset
```

`testimony transcribe` derives the offset automatically when it can: it reads the recording's embedded creation timestamp (the `creation_time` tag recorders such as QuickTime write) and subtracts the manifest's `t0_epoch_ms`. If the tag is missing or unreadable, it falls back to 0 — correct whenever you start recording at the moment the session starts. Either way, it prints the offset and its provenance, so the value is never silent.
Which offset applies depends on where the audio came from. A session captured with `testimony record` needs none: capture starts at `t0`, so the offset is 0 by construction and no derivation is attempted. For an external recording (`transcribe -audio`), the offset is derived automatically when it can be: `transcribe` reads the recording's embedded creation timestamp (the `creation_time` tag recorders such as QuickTime write) and subtracts the manifest's `t0_epoch_ms`; if the tag is missing or unreadable, it falls back to 0 — correct whenever you start recording at the moment the session starts. The derived value is then persisted beside the audio in `audio.offset.json`, so a later re-run over the converted `audio.wav` reuses it instead of silently assuming 0. Whatever the path, the command prints the offset and its provenance, so the value is never silent.

Derivation can be wrong — a recorder may stamp the wrong moment, or omit the tag. That is why sessions begin with a spoken marker: saying "session start" aloud at t0 plants a phrase that appears in the transcript at a known session time (roughly zero). If the report looks misaligned, the marker's transcript time reveals the true offset, and an explicit `-offset` overrides derivation entirely. A belt-and-braces anchor, recoverable after the fact.

Expand Down
2 changes: 1 addition & 1 deletion docs/explanation/privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The rule is simple: **raw recordings stay local; only derived text is ever analy

- Your voice recording and any screen recording remain files on your machine. No part of the pipeline uploads them anywhere.
- Speech recognition runs locally — the transcription engines execute on your own hardware, and no part of your session is sent anywhere. The one network request transcription can make is the engine fetching its own model files from their publisher the first time it needs them; your recording plays no part in it, and once the models are on disk the step runs offline.
- The capture server listens on your machine and writes to a local session directory.
- The capture server listens on your machine and writes to a local session directory. One disclosure sits alongside it: the demo page loads its session-replay recorder (rrweb) from a public CDN, so the participant's browser makes one request to that third party — which thereby sees an IP address and the time of the session, though never any session content. Offline, or with the CDN blocked, the demo still works and still captures the interaction stream; only the archival replay stream stays empty.
- What the pipeline produces for analysis is derived text: the transcript, the normalised event stream, the merged timeline, and the report. These are small, readable files you can inspect line by line before sharing them with anyone — or with any analysis tool.

The distinction matters because the derived text is a much narrower disclosure than the recording it came from. A transcript contains what was said; the audio contains a voiceprint. An event stream says a button was clicked; a screen recording shows everything else that was visible at the time. When an analysis layer (local or cloud) enters the picture, it sits on the far side of this boundary: it sees only the text you choose to give it, never the raw audio or video. If your setting demands it, a fully local analysis path keeps even the derived text on the machine.
Expand Down
4 changes: 2 additions & 2 deletions docs/how-to/instrument-your-own-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ This creates a fresh session directory (with `manifest.json` anchoring the sessi
| `POST /api/interactions` | one normalised interaction (a single JSON object) | `interactions.jsonl` (one line per request) |
| `POST /api/events` | a batch of raw rrweb events (a JSON array) | `events.rrweb.jsonl` (one line per array element) |

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 invalid JSON with 400; `/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.
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.

Expand All @@ -36,7 +36,7 @@ Each interaction is one JSON object. The fields the pipeline consumes:
| Field | Type | Meaning |
|---|---|---|
| `t` | integer, **required** | event time in epoch milliseconds (`Date.now()`) |
| `kind` | string | event kind, e.g. `"click"` or `"input"` |
| `kind` | string, **required** | event kind, e.g. `"click"` or `"input"` |
| `selector` | string | CSS-like anchor, ideally `[data-testid=...]` |
| `text` | string | short human-readable label of the element |
| `value` | string | new value for input events |
Expand Down
Loading