From 996b1eb40a6f5349ddf23dd3f7c21da1f12d3be5 Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sat, 24 Oct 2026 17:44:53 +0800 Subject: [PATCH] fix(audit): parse only the final turn of long transcripts The Stop gate's snapshot reader decoded the whole transcript with JSON::PP to find the last real user message, at about 5 MiB/s. To stay inside its 15 s alarm, callers capped the file at 64 MiB. Transcripts only grow, so once a session passed the cap, every Stop produced an incomplete snapshot that the auditor must FAIL, blocking the session for good. Scan back from the end of the file instead and decode only the final turn, at most 64 MiB (verdict_audit_final_turn_max_bytes). Earlier history is hashed for source_sha256, and the decoded turn bytes must hash the same in the full-file pass, so the snapshot contract and its stability checks stay as they were. A 52.8 MiB transcript drops from about 10 s to 0.3 s. Rejected: raising the cap and alarm (cost still grows with the session), JSON::XS when installed (not core Perl), recording the turn offset at UserPromptSubmit (new cross-hook state), and last_assistant_message alone (no tool evidence for the auditor). Design: https://github.com/boxlite-ai/agent-tooling/issues/262 --- .../.agents/hooks/README.md | 7 +- .../hooks/audit/preflight-verdict-check.sh | 6 +- .../audit/preflight-verdict-check.test.sh | 109 ++++++++++++++- .../.agents/hooks/audit/run-verdict-audit.sh | 7 +- .../.agents/lib/reply-summary.sh | 6 +- .../.agents/lib/verdict-audit-state.sh | 124 ++++++++++++------ plugins/boxlite-agent-tooling/ARCHITECTURE.md | 1 + 7 files changed, 210 insertions(+), 50 deletions(-) diff --git a/plugins/boxlite-agent-tooling/.agents/hooks/README.md b/plugins/boxlite-agent-tooling/.agents/hooks/README.md index 0971884..fac56c0 100644 --- a/plugins/boxlite-agent-tooling/.agents/hooks/README.md +++ b/plugins/boxlite-agent-tooling/.agents/hooks/README.md @@ -115,6 +115,11 @@ flowchart TD | **Incomplete snapshot** | Gate | Evidence could not be captured completely | | **Dossier** | Auditor | Its assessment: PASS, FAIL, or IN_PROGRESS, with findings | +The gate reads the transcript back from its end to the last real user message +([reader](../lib/verdict-audit-state.sh)). It decodes at most 64 MiB of that final +turn and only hashes earlier history, so a long session never makes evidence +incomplete ([#262](https://github.com/boxlite-ai/agent-tooling/issues/262)). + The runner gives the auditor a [private copy of the snapshot](audit/run-verdict-audit.sh). Re-entry means running the gate again to validate the returned dossier against the current session, audit generation, and repository state. @@ -123,7 +128,7 @@ current session, audit generation, and repository state. The regression test follows this sequence: -1. The **65 MiB original transcript** exceeds the reader's limit. +1. The **65 MiB original transcript** holds only NUL bytes, which the reader refuses. 2. The gate saves an **incomplete snapshot** containing `truncated: true` and `records: []`. 3. The test deletes the **original transcript**. 4. The fix preserves the snapshot through retries and reloads it during **gate re-entry**. diff --git a/plugins/boxlite-agent-tooling/.agents/hooks/audit/preflight-verdict-check.sh b/plugins/boxlite-agent-tooling/.agents/hooks/audit/preflight-verdict-check.sh index f8b4b61..88978ca 100755 --- a/plugins/boxlite-agent-tooling/.agents/hooks/audit/preflight-verdict-check.sh +++ b/plugins/boxlite-agent-tooling/.agents/hooks/audit/preflight-verdict-check.sh @@ -240,7 +240,7 @@ classifier_timeout_seconds=20 classifier_max_bytes=12000 classifier_output_max_bytes=4096 verdict_output_max_bytes=8192 -transcript_source_max_bytes=67108864 +extractor_transcript_max_bytes=67108864 transcript_turn_max_bytes=262144 transcript_snapshot_truncated=false @@ -1623,7 +1623,7 @@ prepare_audit_transcript() { mkdir -p "$(dirname "$payload_transcript_file")" 2>/dev/null || return 1 if [[ -n "${VERDICT_EXTRACTOR_CMD:-}" ]] \ && raw_snapshot="$(verdict_audit_read_regular_state \ - "$source_path" "$transcript_source_max_bytes" json 2>/dev/null)" \ + "$source_path" "$extractor_transcript_max_bytes" json 2>/dev/null)" \ && [[ "$raw_snapshot" == *$'\n'* ]]; then raw_body="${raw_snapshot#*$'\n'}" if ! staged_identity="$(printf '%s' "$raw_body" \ @@ -1666,7 +1666,7 @@ prepare_audit_transcript() { fi elif staged_identity="$(verdict_audit_snapshot_final_turn_identity \ "$source_path" "$staged" \ - "$transcript_source_max_bytes" "$transcript_turn_max_bytes")"; then + "$verdict_audit_final_turn_max_bytes" "$transcript_turn_max_bytes")"; then snapshot_state="$(verdict_audit_read_regular_state \ "$staged" "$transcript_turn_max_bytes" json \ "$staged_identity" 2>/dev/null)" || snapshot_state="" diff --git a/plugins/boxlite-agent-tooling/.agents/hooks/audit/preflight-verdict-check.test.sh b/plugins/boxlite-agent-tooling/.agents/hooks/audit/preflight-verdict-check.test.sh index 0e4f766..8e30ea4 100755 --- a/plugins/boxlite-agent-tooling/.agents/hooks/audit/preflight-verdict-check.test.sh +++ b/plugins/boxlite-agent-tooling/.agents/hooks/audit/preflight-verdict-check.test.sh @@ -3069,6 +3069,111 @@ else fail=$((fail+1)); printf ' FAIL %s (out=%s)\n' "codex turn-level" "$out" fi; rm -rf "$R" +echo +echo "## Final-turn reader: parsing tracks the turn, not the session" +# The reader scans back from the end of the transcript to the last real user message, +# so earlier history is hashed, never parsed. A whole-file cap made every Stop after a +# session passed 64 MiB fail as unreadable evidence (#262). +R="$(setup)" +perl -e 'my $pad = "x" x 1048000; print qq({"type":"progress","data":"$pad"}\n) for 1 .. 66' \ + > "$R/transcript.jsonl" +append_user "$R" "How is the session going?" +append_assistant "$R" "Nothing here is a verdict; just chatting." +long_decision="$(decide "$R")" +long_rung="none" +grep -q 'extract truncated-block' "$R"/.agents/state/verdict-decisions.log* 2>/dev/null \ + && long_rung="extract truncated-block" +if [[ "$long_decision" == allow && "$long_rung" == none ]]; then + pass=$((pass + 1)) + printf ' PASS a 66 MiB session with a small final turn is judged, not truncated\n' +else + fail=$((fail + 1)) + printf ' FAIL a 66 MiB session with a small final turn is judged, not truncated (decision=%s rung=%s)\n' \ + "$long_decision" "$long_rung" +fi +rm -rf "$R" + +snapshot_of() { # transcript turn-max -> snapshot JSON; non-zero when the reader refuses + local dir rc=0 + dir="$(mktemp -d)" + ( source "$REPO_ROOT/.agents/lib/verdict-audit-state.sh" + verdict_audit_snapshot_final_turn "$1" "$dir/snapshot.json" "$2" 262144 ) 2>/dev/null \ + && cat "$dir/snapshot.json" || rc=1 + rm -rf "$dir" + return "$rc" +} +sha256_of() { perl -MDigest::SHA -e 'print Digest::SHA->new(256)->addfile($ARGV[0])->hexdigest' "$1"; } + +# Lines come back exactly as a forward read gives them: one spanning several 64 KiB +# blocks, a blank line, CRLF, and a last line without its LF. +R="$(mktemp -d)"; t="$R/transcript.jsonl" +long_claim="$(perl -e 'print "y" x 153600')" +jq -nc '{type:"assistant", message:{content:[{type:"text",text:"OLD_TURN_CLAIM"}]}}' > "$t" +jq -nc '{type:"user", message:{content:[{type:"text",text:"go on"}]}}' >> "$t" +jq -nc --arg c "$long_claim" '{type:"assistant", message:{content:[{type:"text",text:$c}]}}' >> "$t" +printf '\n' >> "$t" +printf '%s\r\n' "$(jq -nc '{type:"assistant", message:{content:[{type:"text",text:"crlf"}]}}')" >> "$t" +printf '%s' "$(jq -nc '{type:"assistant", message:{content:[{type:"text",text:"no final LF"}]}}')" >> "$t" +boundary_snapshot="$(snapshot_of "$t" 67108864)" || boundary_snapshot="" +boundary_texts="$(jq -c '[.records[] | select(.kind == "assistant") | .texts[]]' \ + <<<"$boundary_snapshot" 2>/dev/null)" +if [[ "$boundary_texts" == "$(jq -nc --arg c "$long_claim" '[$c, "crlf", "no final LF"]')" \ + && "$(jq -r '.source_sha256' <<<"$boundary_snapshot")" == "$(sha256_of "$t")" \ + && "$(jq -r '.source_bytes' <<<"$boundary_snapshot")" == "$(wc -c < "$t" | tr -d ' ')" ]]; then + pass=$((pass + 1)) + printf ' PASS block-spanning, blank, CRLF and unterminated lines read as a forward read does\n' +else + fail=$((fail + 1)) + printf ' FAIL block-spanning, blank, CRLF and unterminated lines read as a forward read does (texts=%.80s)\n' \ + "$boundary_texts" +fi +rm -rf "$R" + +# The decode bound covers the final turn, not the file. +R="$(mktemp -d)"; t="$R/transcript.jsonl" +perl -e 'print qq({"type":"progress","data":"), "z" x 100000, qq("}\n)' > "$t" +jq -nc '{type:"user", message:{content:[{type:"text",text:"go on"}]}}' >> "$t" +jq -nc '{type:"assistant", message:{content:[{type:"text",text:"small turn"}]}}' >> "$t" +small_turn_rc=0; snapshot_of "$t" 4096 >/dev/null || small_turn_rc=$? +jq -nc --arg c "$(perl -e 'print "w" x 8192')" \ + '{type:"assistant", message:{content:[{type:"text",text:$c}]}}' >> "$t" +large_turn_rc=0; snapshot_of "$t" 4096 >/dev/null || large_turn_rc=$? +if [[ "$small_turn_rc" == 0 && "$large_turn_rc" != 0 ]]; then + pass=$((pass + 1)); printf ' PASS the decode bound covers the final turn, not the file\n' +else + fail=$((fail + 1)) + printf ' FAIL the decode bound covers the final turn, not the file (small=%s large=%s)\n' \ + "$small_turn_rc" "$large_turn_rc" +fi +rm -rf "$R" + +# History is hashed, not parsed: NUL anywhere still fails, as does a malformed final-turn +# line, but a malformed line before the last real user message no longer does. +R="$(mktemp -d)" +for history_case in nul malformed-turn malformed-history; do + t="$R/$history_case.jsonl" + case "$history_case" in + nul) printf '{"type":"progress","data":"a\0b"}\n' > "$t" ;; + malformed-turn) printf '{"type":"progress","data":"ok"}\n' > "$t" ;; + malformed-history) printf '{"type":"progress","data":\n' > "$t" ;; + esac + jq -nc '{type:"user", message:{content:[{type:"text",text:"go on"}]}}' >> "$t" + jq -nc '{type:"assistant", message:{content:[{type:"text",text:"turn"}]}}' >> "$t" + [[ "$history_case" == malformed-turn ]] && printf '{"type":"assistant","message":\n' >> "$t" +done +nul_rc=0; snapshot_of "$R/nul.jsonl" 67108864 >/dev/null || nul_rc=$? +turn_rc=0; snapshot_of "$R/malformed-turn.jsonl" 67108864 >/dev/null || turn_rc=$? +history_rc=0; snapshot_of "$R/malformed-history.jsonl" 67108864 >/dev/null || history_rc=$? +if [[ "$nul_rc" != 0 && "$turn_rc" != 0 && "$history_rc" == 0 ]]; then + pass=$((pass + 1)) + printf ' PASS NUL and malformed final-turn lines fail; malformed history is only hashed\n' +else + fail=$((fail + 1)) + printf ' FAIL NUL and malformed final-turn lines fail; malformed history is only hashed (nul=%s turn=%s history=%s)\n' \ + "$nul_rc" "$turn_rc" "$history_rc" +fi +rm -rf "$R" + echo echo "## Harness-agnostic extraction: conventions, not schema lists" R="$(setup)"; write_codex_transcript "$R" "Root cause is the missing bind mount; all tests pass now." @@ -4216,8 +4321,8 @@ echo "## a transcript unreadable at one moment is re-read, not judged incomplete # The live failure: the gate reads the transcript while the harness is still appending # to it, the extractor's two passes disagree on the bytes, and the hollow snapshot it # publishes reads downstream as "evidence incomplete" — forcing a FAIL dossier about a -# turn nobody actually looked at. A regular file the extractor refuses on sight (over -# the source ceiling) reproduces that moment deterministically, and a helper swaps in +# turn nobody actually looked at. A regular file the extractor refuses on sight (only +# NUL bytes) reproduces that moment deterministically, and a helper swaps in # the settled transcript inside the retry window. R="$(setup)" write_transcript "$R" "Nothing here is a verdict; just chatting." diff --git a/plugins/boxlite-agent-tooling/.agents/hooks/audit/run-verdict-audit.sh b/plugins/boxlite-agent-tooling/.agents/hooks/audit/run-verdict-audit.sh index 0fdd3cb..ce74d54 100755 --- a/plugins/boxlite-agent-tooling/.agents/hooks/audit/run-verdict-audit.sh +++ b/plugins/boxlite-agent-tooling/.agents/hooks/audit/run-verdict-audit.sh @@ -65,7 +65,6 @@ audit_verified_identity="" audit_success_pending=false transcript_snapshot_dir="" transcript_snapshot_path="" -transcript_source_max_bytes=67108864 transcript_turn_max_bytes=262144 previous_dossier_max_bytes=65536 previous_dossier_input_path="" @@ -754,9 +753,9 @@ snapshot_transcript() { fi elif ! verdict_audit_snapshot_final_turn \ "$transcript_path" "$transcript_snapshot_path" \ - "$transcript_source_max_bytes" "$transcript_turn_max_bytes"; then - printf 'run-verdict-audit.sh: transcript must be stable, regular JSONL no larger than %s bytes; auditor was not started.\n' \ - "$transcript_source_max_bytes" >&2 + "$verdict_audit_final_turn_max_bytes" "$transcript_turn_max_bytes"; then + printf 'run-verdict-audit.sh: transcript must be stable, regular JSONL whose final turn is at most %s bytes; auditor was not started.\n' \ + "$verdict_audit_final_turn_max_bytes" >&2 return 1 fi transcript_path="$transcript_snapshot_path" diff --git a/plugins/boxlite-agent-tooling/.agents/lib/reply-summary.sh b/plugins/boxlite-agent-tooling/.agents/lib/reply-summary.sh index 57c5759..d1ec1e4 100644 --- a/plugins/boxlite-agent-tooling/.agents/lib/reply-summary.sh +++ b/plugins/boxlite-agent-tooling/.agents/lib/reply-summary.sh @@ -8,8 +8,7 @@ # Readability thresholds are independent of the shared prompt's prose budget. reply_summary_paragraph_max_words=80 reply_summary_item_max_words=40 -# The same bounds the verdict check puts on the transcript it reads. -reply_summary_transcript_max_bytes=67108864 +# The same snapshot bound the verdict check puts on the transcript it reads. reply_summary_snapshot_max_bytes=262144 # A word is a whitespace-separated token holding a letter or digit, so table pipes, @@ -127,8 +126,9 @@ reply_summary_is_dense() { # text _reply_summary_snapshot() { # transcript-path scratch-dir -> bounded JSON local snapshot="$2/final-turn.json" identity state [[ -n "${1-}" && -f "$1" && ! -L "$1" && -d "${2-}" ]] || return 1 + # shellcheck disable=SC2154 # Assigned by verdict-audit-state.sh, which callers source. identity="$(verdict_audit_snapshot_final_turn_identity "$1" "$snapshot" \ - "$reply_summary_transcript_max_bytes" "$reply_summary_snapshot_max_bytes" \ + "$verdict_audit_final_turn_max_bytes" "$reply_summary_snapshot_max_bytes" \ 2>/dev/null)" || return 1 state="$(verdict_audit_read_regular_state "$snapshot" \ "$reply_summary_snapshot_max_bytes" json "$identity" 2>/dev/null)" || return 1 diff --git a/plugins/boxlite-agent-tooling/.agents/lib/verdict-audit-state.sh b/plugins/boxlite-agent-tooling/.agents/lib/verdict-audit-state.sh index c41d383..012dbad 100644 --- a/plugins/boxlite-agent-tooling/.agents/lib/verdict-audit-state.sh +++ b/plugins/boxlite-agent-tooling/.agents/lib/verdict-audit-state.sh @@ -9,6 +9,11 @@ verdict_audit_lock_max_seconds=3600 # shellcheck disable=SC2034 # Public constants for scripts that source this library. verdict_audit_deadline_slack_seconds=60 +# Most transcript bytes the final-turn reader decodes, from the end of the file back to +# the last real user message. JSON::PP decodes about 5 MiB/s, so this fits the reader's +# 15 s alarm; earlier history is only hashed, so session length alone never trips it. +# shellcheck disable=SC2034 # Public constants for scripts that source this library. +verdict_audit_final_turn_max_bytes=67108864 # Persisted decimal fields are untrusted. Keep them below 10^18 so Bash 3's signed # integer arithmetic cannot wrap a huge value into the live deadline window. The caller @@ -240,19 +245,21 @@ verdict_audit_copy_regular_state() { # source destination max-bytes ' "$1" "$2" "$3" } -# Stream one selected JSONL transcript inode and retain only records after the last real -# user message. Historical turns and harness envelopes never enter the model-visible -# snapshot. Assistant claims stay whole; large tool events become bounded previews plus -# size/hash metadata. Claim truncation is fail-closed. Summarized evidence remains usable -# only when the auditor can independently reproduce what a claim depends on. -_verdict_audit_snapshot_final_turn() { # source destination source-max snapshot-max emit-identity - local source="$1" destination="$2" max_source_bytes="$3" max_snapshot_bytes="$4" +# Select one JSONL transcript inode and retain only records after the last real user +# message. Parsing scans back from the end of the file, so its cost tracks the final turn, +# not the session; earlier history is only hashed. Historical turns and harness envelopes +# never enter the model-visible snapshot. Assistant claims stay whole; large tool events +# become bounded previews plus size/hash metadata. Claim truncation is fail-closed. +# Summarized evidence remains usable only when the auditor can independently reproduce +# what a claim depends on. +_verdict_audit_snapshot_final_turn() { # source destination turn-max snapshot-max emit-identity + local source="$1" destination="$2" max_turn_bytes="$3" max_snapshot_bytes="$4" local emit_identity="$5" source_identity snapshot_identity="" snapshot_bytes - [[ "$max_source_bytes" =~ ^[1-9][0-9]*$ \ + [[ "$max_turn_bytes" =~ ^[1-9][0-9]*$ \ && "$max_snapshot_bytes" =~ ^[1-9][0-9]*$ \ && ( "$emit_identity" == 0 || "$emit_identity" == 1 ) ]] || return 1 source_identity="$(perl -MFcntl=:DEFAULT -e ' - my ($path, $max) = @ARGV; + my ($path) = @ARGV; exit 1 if lstat($path) && -l _; my $flags = O_RDONLY | O_NONBLOCK; $flags |= Fcntl::O_NOFOLLOW() if defined &Fcntl::O_NOFOLLOW; @@ -260,16 +267,15 @@ _verdict_audit_snapshot_final_turn() { # source destination source-max snapshot my @opened = stat($fh); my @named = lstat($path); exit 1 unless @opened && @named && -f $fh - && $opened[0] == $named[0] && $opened[1] == $named[1] - && $opened[7] <= $max; + && $opened[0] == $named[0] && $opened[1] == $named[1]; print join(":", @opened[0,1,7,9,10]); - ' "$source" "$max_source_bytes" 2>/dev/null)" || return 1 + ' "$source" 2>/dev/null)" || return 1 if ! snapshot_identity="$( ( set -o pipefail perl -MFcntl=:DEFAULT -MJSON::PP -MDigest::SHA -MEncode=encode_utf8 -MScalar::Util=refaddr -e ' - my ($path, $max_source, $max_snapshot, $expected) = @ARGV; + my ($path, $max_turn, $max_snapshot, $expected) = @ARGV; $SIG{ALRM} = sub { exit 1 }; alarm 15; exit 1 if lstat($path) && -l _; @@ -280,8 +286,7 @@ _verdict_audit_snapshot_final_turn() { # source destination source-max snapshot my @named = lstat($path); exit 1 unless @before && @named && -f $fh && join(":", @before[0,1,7,9,10]) eq $expected - && $before[0] == $named[0] && $before[1] == $named[1] - && $before[7] <= $max_source; + && $before[0] == $named[0] && $before[1] == $named[1]; binmode($fh); my $decoder = JSON::PP->new->utf8->allow_nonref; my $encoder = JSON::PP->new->canonical->allow_nonref; @@ -482,32 +487,55 @@ _verdict_audit_snapshot_final_turn() { # source destination source-max snapshot return "metadata" if $kind =~ /metadata|snapshot|queue|history/i; return "other"; } - sub reset_turn { - @assistant_records = (); - @tool_entries = (); - $claim_bytes = 0; - $final_turn_bytes = 0; - %omitted = (system => 0, progress => 0, metadata => 0, other => 0); - %omitted_bytes = (system => 0, progress => 0, metadata => 0, other => 0); - $sequence = 0; - } - my $total = 0; - while (defined(my $line = <$fh>)) { - $total += length($line); - exit 1 if $total > $max_source || index($line, "\0") >= 0; - $source_digest->add($line); - next if $line =~ /^\s*$/; + # Scan back from the opened size to the last real user message; only those lines + # are decoded. JSON escapes control characters and UTF-8 never reuses ASCII bytes, + # so every LF ends a record wherever a block boundary falls. + my ($position, $pending, $scanned) = ($before[7], "", 0); + my (@turn_lines, @turn_records); + while (1) { + my $last = length($pending) - 1; + my $newline = $last > 0 ? rindex($pending, "\n", $last - 1) : -1; + my $line; + if ($newline >= 0) { + $line = substr($pending, $newline + 1, length($pending), ""); + } elsif ($position > 0) { + # Stop reading at the turn bound, and read as much again as is pending, so one + # long line costs O(n), not O(n^2). + my $room = $max_turn - $scanned - length($pending); + exit 1 if $room < 0; + my $count = length($pending) > 65536 ? length($pending) : 65536; + $count = $room + 1 if $count > $room + 1; + $count = $position if $count > $position; + $position -= $count; + my $block = ""; + sysseek($fh, $position, 0) or exit 1; + my $read = sysread($fh, $block, $count); + exit 1 unless defined($read) && $read == $count && index($block, "\0") < 0; + $pending = $block . $pending; + next; + } elsif (length($pending)) { + ($line, $pending) = ($pending, ""); + } else { + last; + } + $scanned += length($line); + exit 1 if $scanned > $max_turn; + if ($line =~ /^\s*$/) { + push @turn_lines, $line; + next; + } my $record = eval { $decoder->decode($line) }; exit 1 if $@; # A pre-normalized snapshot is accepted only by the bounded schema path. - # path. Never reinterpret an oversized or malformed snapshot as raw history. + # Never reinterpret an oversized or malformed snapshot as raw history. exit 1 if ref($record) eq "HASH" && ($record->{type} // "") eq "verdict_final_turn_snapshot"; - if (is_real_user($record)) { - reset_turn(); - next; - } + last if is_real_user($record); + push @turn_lines, $line; + push @turn_records, $record; + } + for my $record (reverse @turn_records) { $sequence++; my @tools = tool_objects($record); my @texts = assistant_texts($record); @@ -528,6 +556,28 @@ _verdict_audit_snapshot_final_turn() { # source destination source-max snapshot $omitted_bytes{$kind} += $record_bytes; } } + # Hash every byte, so source_sha256 still covers the whole file, and bind the turn + # lines decoded above to the bytes this pass reads. + my ($turn_bytes, $decoded_digest) = (0, Digest::SHA->new(256)); + for my $line (reverse @turn_lines) { + $turn_bytes += length($line); + $decoded_digest->add($line); + } + my $turn_start = $before[7] - $turn_bytes; + my $turn_digest = Digest::SHA->new(256); + my $hashed = 0; + sysseek($fh, 0, 0) or exit 1; + while ($hashed < $before[7]) { + my $chunk = ""; + my $count = sysread($fh, $chunk, 65536); + exit 1 unless $count && index($chunk, "\0") < 0; + $source_digest->add($chunk); + $turn_digest->add(substr($chunk, $turn_start > $hashed ? $turn_start - $hashed : 0)) + if $hashed + $count > $turn_start; + $hashed += $count; + } + exit 1 unless $hashed == $before[7] + && $turn_digest->hexdigest eq $decoded_digest->hexdigest; my $first_digest = $source_digest->hexdigest; my @after = stat($fh); my @named_after = lstat($path); @@ -543,7 +593,7 @@ _verdict_audit_snapshot_final_turn() { # source destination source-max snapshot exit 1 unless defined $count; last if $count == 0; $verified_bytes += $count; - exit 1 if $verified_bytes > $max_source; + exit 1 if $verified_bytes > $before[7]; $verify_digest->add($chunk); } my @verified = stat($fh); @@ -633,7 +683,7 @@ _verdict_audit_snapshot_final_turn() { # source destination source-max snapshot exit 1 if length($output) + 1 > $max_snapshot; alarm 0; print STDOUT $output, "\n" or exit 1; - ' "$source" "$max_source_bytes" "$max_snapshot_bytes" "$source_identity" \ + ' "$source" "$max_turn_bytes" "$max_snapshot_bytes" "$source_identity" \ | verdict_audit_write_exclusive_regular_identity "$destination" ) )"; then diff --git a/plugins/boxlite-agent-tooling/ARCHITECTURE.md b/plugins/boxlite-agent-tooling/ARCHITECTURE.md index c6c6816..238c397 100644 --- a/plugins/boxlite-agent-tooling/ARCHITECTURE.md +++ b/plugins/boxlite-agent-tooling/ARCHITECTURE.md @@ -628,6 +628,7 @@ both attempts in history. See the [design](https://github.com/boxlite-ai/agent-t - Agent to auditor: the auditor spec is the only writer of a dossier. Gates read verdicts and never write them. - Gate to state: each artifact is bound to what it judged. Turn dossier: branch, HEAD, tree hash, generation, session scope, prompt epoch; a mismatch is discarded and the Stop gate falls through to fresh detection (`.agents/hooks/audit/preflight-verdict-check.sh:28`). Commit and push dossier: branch, HEAD, the staged or pushed diff, the command, and for a commit the subject, for 5 hours after it is written; a mismatch or an older dossier denies until a fresh audit (`.agents/hooks/preflight-commit-push.sh:677`). Receipt: parent, tree, subject. Research verdict: root, branch, design URL, and the SHA-256 of the content the auditor reviewed (`.agents/lib/design-doc.sh`). - Incomplete evidence: a missing or empty source during refresh retains the published failed snapshot. Audit re-entry reads and validates that bounded snapshot before consuming the dossier; loss of the original source cannot become `empty-allow`. An initially absent or empty transcript still has nothing to judge. +- Final-turn reader: `.agents/lib/verdict-audit-state.sh` reads a transcript back from its end to the last real user message and decodes at most `verdict_audit_final_turn_max_bytes` (64 MiB) of it. Earlier history is only hashed into `source_sha256`, so session length alone never makes evidence incomplete; the reader's 15 s alarm bounds the rest ([#262](https://github.com/boxlite-ai/agent-tooling/issues/262)). - Stop gate to verdict check: after the TL;DR check and timed confirmations, `.agents/hooks/stop-gate.sh` `exec`s `.agents/hooks/audit/preflight-verdict-check.sh` with the payload unchanged, so the check runs as the hook process: its output, stderr, exit status, and signal handling are the hook's ([#255](https://github.com/boxlite-ai/agent-tooling/issues/255)). Missing dependencies fail closed. - Mandatory summary: `.agents/lib/concise-writing.sh` checks for an ATX TL;DR heading outside fences, quotes, and comments, whose first block is a paragraph opening with a word, emphasis, a link, or inline code. A list item, thematic break, link or footnote definition, setext heading, or table cannot stand in for that paragraph ([#257](https://github.com/boxlite-ai/agent-tooling/issues/257)). Human-facing replies must start there; the entire section has at most 39 words using the shared Unicode-aware counter. Each denial is one line: the failed condition, then a literal fix; length failures report the count and limit and ask for a same-level heading after one short sentence. Missing, misplaced, malformed, and overlong TL;DR denials end with `apply boxlite-writing` unless the caller passes `no-hint`. Only the GitHub writing check does, because the PR gate appends its own reminder. Stop checks the TL;DR only when `stop_hook_active` is false: one correction can continue a turn, then the check is skipped, including after unrelated Stop continuations. GitHub and design-document checks remain mandatory. Missing Stop text falls back to a bounded transcript snapshot; unreadable or truncated snapshots fail closed even on continuations. Explicitly empty reply text remains empty without replaying earlier text; verdict auditing still runs. Internal instructions and tool payloads need no summary. Sentence simplicity remains a writing instruction. - Enforcement scope: Stop checks the final reply after generation; it cannot retract streamed commentary. GitHub checks cover recognized CLI calls, not browser or connector writes. Design checks cover fetched GitHub, Linear, and Notion content; native Notion headings retain their structure. Shared guidance applies to every human-facing output across these surfaces.