Tolerate poisoned session entry payloads and sanitize every Postgres text write - #965
Open
ReganBell wants to merge 11 commits into
Open
Tolerate poisoned session entry payloads and sanitize every Postgres text write#965ReganBell wants to merge 11 commits into
ReganBell wants to merge 11 commits into
Conversation
…il off the user path Review of the public PR: getOrCreateByThread inserted a sanitized channel name but the heal closure compared and re-wrote the raw one, so a NUL name failed the first turn and a lone surrogate re-updated every turn. The name is sanitized once at the top. Sanitized keys are written onto a null-prototype object so a key that cleans to __proto__ survives. The turn ingress uses pgSafeValue, which returns the same object when nothing needs sanitizing. The approval path logs the unclearable-taint detail for operators and fails the turn with plain user-facing text. registerArtifact returns the stored artifact path so grants key on the persisted value. The memory store clears tape meta taint like the Postgres store.
…hole session Review: the non-retryable failure text never reached Slack approvers (the failure clause is generic there) and the outer catch logged the error a second time; the tape taint mirror was only cleared for entries cleared in the same call, so sessions released under older code kept tainted tape rows; the file-write tool granted and audited on the raw path while the artifact row stored the sanitized one. An unclearable-taint approval now returns a refused result whose reason both surfaces render, after one operator error record. The tape mirror is cleared for every tainted tape row of the session, matching the memory store. The write tool sanitizes its path argument once so the workspace write, artifact row, grant and audit share one value.
Review: returning a refused result dropped the Slack card's buttons while the approval stayed pending, and the branch wrote no approval audit row. It now returns the pending-approval shape its siblings use, with a lowercase reason clause, after recording the operator detail and the command_approval audit row. The memory store sanitizes channel names and deletes tape meta taint like the Postgres store. Attachment names and artifact paths are sanitized where they are built, so every consumer of a path shares one value. The dead hasLoneSurrogate export is gone.
Review: the reason on a still-pending approval is rendered only when the result carries no pending list (Slack redraws the card with buttons and the note; the web shows it as the submit error), so return that shape and drop the duplicated approval literal. The taint probe matches the key and colon rather than the compact key:true, so a flag written in jsonb's spaced form still reads tainted and still clears; the parsed value decides. The fast-path regex anchors on an odd backslash run so text that merely quotes an escape no longer takes the slow path. The write tool rejects a path with a NUL or unpaired surrogate instead of silently renaming the file. Channel file mimetype is sanitized like its sibling columns.
…ent errors Review: the clear path judged readability with JSON.parse while the read path used safe_jsonb, so a row Postgres rejects but V8 accepts could stay tainted with its approval consumed; a transient database error during the clear burned three retries and left a buttonless card; identifiers that change under normalization slipped past route validation; the web queued path dropped the pending reason; tape coverage parsed every annotation. The clear query asks Postgres whether each candidate parses and treats a NULL as unreadable. Non-taint errors during the clear return the pending-approval shape with a retry reason after one error record. A request whose actor id or idempotency key would change under normalization is refused. Pending reasons render on the web. The tape coverage scan prefilters on the turnEnd key.
…int flag Review: normalizing the whole request rewrote identifiers such as the thread ref and approval request id, so sibling endpoints keyed on the raw value missed; the audit log threw on a non-string resource; attachment names were sanitized after trimming so a NUL could shield trailing spaces; two dirty keys cleaning to the same key collapsed last-wins; the flag removal was written three times. Only the five stored-and-compared content fields are normalized, so identifiers are never rewritten. Audit resources are stringified before sanitizing. Attachment names are sanitized before basename and trim. Colliding dirty keys keep the first value. withoutSecurityTaint is the one helper both stores use.
… not shadow Review: the transient-error branch of the approval clear wrote no audit row; the Slack card appends a sentence after the reason, so reasons on that path must be full sentences; a clean twin key holding undefined shadowed a dirty key. Both failure branches now write the command_approval audit row, with the row list or the error message in the detail. Reasons rendered by the pending-approval note are full sentences. A clean key wins over a dirty twin only when it carries a value.
…esources Review: the sanitized-key helper could drop a value depending on source key order; stringifying a non-string audit resource persisted the text "undefined"; registerArtifact hand-built the path the shared helper now sanitizes; the context_event tape filter parsed every row; the memory store's tape loop re-implemented the flag removal. The helper now keeps source order, lets a clean key with a value win, and otherwise keeps the first dirty key. Only string audit resources are sanitized. registerArtifact uses artifactPath. The context_event filter prefilters on its key. The memory store uses withoutSecurityTaint for tape meta too.
ReganBell
force-pushed
the
fix/nul-safe-context-window
branch
from
September 7, 2026 20:15
6108c15 to
6a38608
Compare
Review: the approval branch that handles an unreleasable taint had no orchestrator-level test, and the audit log built its sanitized row twice. A test flags an inbound message, makes the store throw TaintUnclearableError on release, and checks the pending result, its reason, the refused audit row, the operator error record, and that the same approval succeeds once the store can clear again. The audit log builds its row once for both insert paths.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rebased onto
mainafter the runtime consolidation merged; this is the same change, eleven small commits, 39 files.What broke
A session whose stored history contained one legacy entry with a
\u0000escape failed every turn with:That is Postgres refusing to cast text to
jsonbwhen the text contains the six-character escape\u0000.jsonbcannot store NUL;text(andjson) can. The compaction-window change (thegetContextWindowread) casts everysession_entries.payloadof the session tojsonbon every turn, so a single such entry made every turn and every background compaction die before the model was called. The affected rows predate the write-side NUL stripping and were only three across one deployment, but one of them sat in an active session.The producer
The rows came from
extractDirectivesin the Slack layer: it masks code spans with a\u0000CODE<n>\u0000placeholder and handed the masked text to the directive callback, so an agent-to-agent task that quoted inline code was stored with the placeholder. Capture groups are now unmasked before the callback, so the placeholder never leaves the function.Read side
An every-boot maintenance statement defines
safe_jsonb(text)(returnsjsonb, orNULLwhen the text will not parse), mirroring the existingsafe_json. It is eagerly validated (so a lone-surrogate row no longer throws out of->>) and repairs the text inside its exception branch when a clean parse fails, with one tokenizingregexp_replace: escaped backslashes and valid surrogate pairs are consumed as tokens and kept; NUL and lone-surrogate escapes are dropped. No lookbehind, so it is linear (100k escapes in about 0.1 s). Verified on Postgres 16 across eighteen cases including adjacent escapes and escaped-backslash literals next to real escapes.Every read of a stored payload (taint check, summary lookup, tape coverage, admin previews, search authors) parses with it behind a
LIKEprefilter, each row once (OFFSET 0fences), so one poisoned row cannot abort a query. The summary lookup is a flat backward walk of the primary key underLIMIT 1.clearSecurityTaintreads the candidate rows, drops the flag in JavaScript and writes them back compact through the same serializer every write uses, so cleared rows keep matching the compact substring probes elsewhere in the store and the Postgres and memory stores share one rule through a singlewithoutSecurityTainthelper. It also clears the mirroredsession_tape.security_taintedcolumn for every tainted tape row of the session, so sessions released before this change catch up; the memory store does the same.The taint check fails closed: candidate rows are those whose text contains the key and colon
"securityTainted":(which also matches jsonb's spaced form, since the space follows the colon), the parsed top-level value decides, and a candidate that Postgres cannot parse even after repair counts as tainted on both the read and the clear path, so the two cannot disagree about a row. A tainted row nothing can parse makesclearSecurityTaintclear nothing and throw a typedTaintUnclearableError. The input-approval path clears taint before consuming the approval; on that error it records the detail (session and rows) to the operator error log and the approval audit, then returns a pending-approval result carrying only a plain reason, which Slack renders as a note above a redrawn card with its buttons and the web shows to the user, so the approver learns an operator must repair the history and the approval stays answerable. A transient database error during the clear takes the same shape with a retry hint instead of burning the run's attempts; both branches write the approval audit row. The base swallowed every failure here; other errors now propagate. The admin release endpoint maps it to 409 with the row list and audits the refusal.Write side
jsonbSafeStringifyappliespgTextSafe(NUL dropped, lone surrogates replaced with U+FFFD via the built-in well-formedness methods) to every string and key and is used by every jsonb writer: session entries, tape, run signals, deliveries, surface cache, channel policy, run activity.durable-maphad its own copy; it is folded in. TEXT columns that carry user or model text go throughpgTextSafeas well (tape text, delivery text, surface-cache author and text, session and participant titles, pins, search rows, run-signal text, standing orders, ack-emoji picks, ambient judgments, file artifact names and mimetypes, audit detail and resource, scoped event sinks, process commands, environment names, directory display names, session channel names, fork titles, memory revision bodies). Identity keys (ACL refs, dedupe keys) are deliberately left alone. The five request fields that are stored and later compared (text, display text, conversation header, overheard messages, attachments) are normalized once where the request entersapp-turnwithpgSafeValue, which returns the same object when nothing needs sanitizing, so stored text and request text agree in every downstream compare at no cost on the common path. Identifiers (thread refs, actor ids, request and idempotency keys, ACL refs) are never rewritten. Session channel names are sanitized once at the top of the thread lookup so the heal compare and the insert see the same value; attachment names and artifact paths are sanitized where they are built, and the file-write tool rejects a path carrying a NUL or unpaired surrogate instead of silently renaming the file, so ACL grants, audits and artifact rows share one value. The helper only re-serializes when the plain output carries an escape jsonb rejects.Tests
test/context-window.test.ts(added totest:pg) seeds legacy NUL-escape and lone-surrogate rows, a scalar-string payload quoting the flag, a row whose text literally contains\u0000, a row with a nested flag inside tool arguments, a mid-emoji slice, a poisoned latest summary, and a flag rewritten in jsonb's spaced form, then asserts the window resolves, taint surfaces and clears on every row, healthy rows round-trip byte-identical and compact, and the slice was stored as U+FFFD. Further tests cover the orchestrator's approval branch when release fails (pending result with reason, refused audit row, operator error record, same approval succeeding after repair), a session whose only tainted row is poisoned, a tainted row nothing can parse (fails closed, throws naming the row, leaves other rows untouched), the 409 route with its audit row, directive capture groups carrying quoted code, and key sanitization. All fail on the base branch.Follow-ups (pre-existing, not changed here)
entry_search_text(the generated search column) still strips the NUL escape unconditionally and returns NULL when the result will not parse, so a message whose text literally quotes\\u0000, and legacy lone-surrogate rows, preview but do not search. This predates the change. Redefining the function oversafe_jsonbis cheap, but the STORED column only recomputes on write, so existing rows need a backfill; deferred with that.safe_jsonhas no callers after this change but stays defined by the frozen first migration; dropping it must wait until no older build runs against the database.rowToEntrystill parses with a bareJSON.parse, so a row malformed beyond the NUL and surrogate classes (nothing writes those) would still fail reads that reach it;TaintUnclearableErrornames such rows when they are tainted.clearSecurityTaintpreviously re-serialized rows through jsonb, which produced the spaced form"overheard": truethat the compactuserTurnprobes miss; rows already rewritten that way are unaffected by this change.pgTextSafewrappers on TEXT params could collapse into one sanitizer at the pool layer.rowToEntrystill parses with a bareJSON.parse; a row malformed beyond the NUL and surrogate classes (nothing writes those) inside the live window would still fail reads, and there is no admin tool to rewrite or drop a single entry. Asecurity_taintedcolumn onsession_entries(the tape already has one) would replace the substring probes entirely; both are bounded follow-ups rather than part of this fix.safe_jsonbbody may still hold a dead expression index on it; drop it withDROP INDEX CONCURRENTLYout of band.