Skip to content

fix(redact): route JSONL payloads through the structure-aware pass - #244

Merged
ahammadnafiz merged 1 commit into
tryatlas:0.3.1from
MaxFreedomPollard:maxfreedompollard/redact-auto-jsonl-dispatch
Sep 7, 2026
Merged

ahammadnafiz merged 1 commit into
tryatlas:0.3.1from
MaxFreedomPollard:maxfreedompollard/redact-auto-jsonl-dispatch

Conversation

@MaxFreedomPollard

Copy link
Copy Markdown
Contributor

What?

redact_auto sent JSONL payloads to the flat redactor instead of the JSON walker. redact_json has handled JSONL since it was written ("Accepts a single JSON value or JSONL (one value per line)"); only the dispatch in front of it could not name the shape, so the walker was never reached.

Why?

The dispatch decides by parsing the whole input as one serde_json::Value, and JSONL parses as no single value, so it fell through to redact. Three things follow, and they are the three reasons the walker exists.

Capture::record_tool_call in crates/atlas-checkpoint/src/capture.rs scrubs every tool arguments and result through redact_auto, and its own doc comment names the first two: the flat pass "shreds the identifiers the walk exists to protect ({"message_id": …})", because every id clears the entropy threshold, and it counts one replacement per line rather than one per secret, which inflates the redaction tally scrub_or_flag hands to add_redaction_counts and the app shows on the promotion and import confirmations.

The third is worse. A flat span that runs past a JSON escape leaves a line that no longer parses. This input:

{"cmd": "psql \"host=db.internal user=app password=hunter2 sslmode=require\""}

came back as:

{"cmd": "psql \"[REDACTED]""}

The keyword-DSN span ends at require\, taking the backslash of the closing \" with it, so the string terminates early and the trailing quote is stray. The walker never has this problem because serde re-escapes the leaf on the way out.

None of this is hypothetical for Atlas. ACP-hosted Claude Code writes JSONL transcripts that crates/atlas-checkpoint/src/import.rs already reads, and an agent that cats, tails or greps one of those files puts JSONL straight into a tool result, which is the highest-risk content in a Session.

How?

is_jsonl claims an input when it has two or more lines and every non-empty line parses as its own JSON object or array; redact_auto hands those to redact_json. It is strict on purpose, so a payload that merely opens with a brace and then continues in prose keeps the flat path. It stops at the first line that does not parse, so a non-JSONL payload costs one line rather than a second parse of the whole input, and it only runs at all once the existing whole-input parse has already failed.

a_jsonl_payload_takes_the_structured_path_too in crates/atlas-redact/tests/auto.rs covers all three symptoms in one fixture: both ids survive, both secrets are gone, the count is 2 rather than 3, and every output line still parses. On the unmodified source it fails at the first shredded id. a_multi_line_payload_that_is_not_jsonl_still_takes_the_flat_path pins the other direction, and the JSONL case is added to the existing redact_auto_is_idempotent list.

Verified on macOS 26.2 with rustc 1.95.0, running what CI runs per crate:

command result
cd crates/atlas-redact && cargo test --locked 119 passed, 0 failed
cd crates/atlas-redact && cargo clippy --locked --all-targets clean
cd crates/atlas-redact && cargo clippy --locked --all-targets -- -D warnings clean
the same suite with src/lib.rs reverted, tests kept a_jsonl_payload_takes_the_structured_path_too FAILED, "message id shredded"
cd crates/atlas-checkpoint && cargo test --locked 365 passed, 0 failed

Checklist

  • Targets the current version branch, unless it's a small standalone fix
  • New behaviour has a test; a bug fix has a test that fails without it
  • You've run the app and used the change in a window. Verified through atlas-checkpoint's suite instead, which is the crate that calls redact_auto.

`redact_auto` picks between the JSON walker and the flat pass by parsing
the whole input as one `serde_json::Value` (`crates/atlas-redact/src/lib.rs`,
the `starts_with('{')` branch). JSONL parses as no single value, so every
ndjson tool result, `jq -c` run and transcript read a line at a time fell
through to `redact` even though `redact_json` has handled that shape since
it was written.

Three things went wrong on that path, all of them the reasons the walker
exists. Every `message_id` cleared the entropy threshold and came back
`[REDACTED]`. The tally counted one replacement per line rather than one
per secret, which inflates the "N secrets redacted" figure the promotion
and import confirmations show. And a flat span that ran past a JSON escape
left a line that no longer parses:

  {"cmd": "psql \"host=db.internal user=app password=hunter2 sslmode=require\""}

came back as

  {"cmd": "psql \"[REDACTED]""}

`is_jsonl` claims an input when it has two or more lines and every
non-empty one parses as its own object or array, and `redact_auto` hands
those to `redact_json`. The check is strict on purpose, so a prose payload
that merely opens with a brace keeps the flat path, and it stops at the
first line that does not parse, so a non-JSONL payload costs one line
rather than a second parse of the whole input.

`a_jsonl_payload_takes_the_structured_path_too` in `tests/auto.rs` covers
all three symptoms and fails on the unmodified source at the shredded id.
`a_multi_line_payload_that_is_not_jsonl_still_takes_the_flat_path` pins
the other direction, and the JSONL case is added to the existing
idempotence list.
@pacifio

pacifio commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

@ahammadnafiz please review this

@ahammadnafiz ahammadnafiz left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reproduced everything in the description on d9ee007: atlas-redact 119 passed, clippy clean under -D warnings, atlas-checkpoint 365 passed, and with src/lib.rs reverted the new test fails at auto.rs:160 with "message id shredded". The dispatch was an oversight rather than a decision (the commit that added redact_auto never mentions line-delimited input), and record_tool_call passes the flattened tool output as raw text, so the motivating case is real. Merging.

One thing worth a follow-up, not a blocker. is_jsonl bails on the first line that does not parse, and Atlas itself produces that shape for large terminal output: OutputBuffer::push (crates/atlas-terminal/src/command.rs:104) drops from the front at a char boundary once output passes the 1 MiB limit, and terminal_output (crates/atlas-acp-thread/src/thread.rs:1985) prepends [earlier output dropped]\n. So a cat/tail of a transcript over 1 MiB gets all three symptoms back. With your fixture plus one cut-off fourth line:

redact_auto:  count=6, both message_ids -> [REDACTED], line 3 -> {"cmd": "psql \"[REDACTED]""}
redact_json:  count=4, both ids kept, lines 1-3 parse, only the cut line takes the flat fallback

redact_json's per-line fallback already handles this (tests/robustness.rs:173). The strictness is a fair call for a prose log that opens with a brace; something like "every line except the first and last must parse" keeps that guard and covers the truncation marker. A test with the marker would pin whichever way you go.

Separately, the escape-eating is a flat-layer bug that stays reachable for prose: the keyword-DSN value class in crates/atlas-redact/src/dsn.rs:51 is [^\s"']+ and does not exclude a backslash. the tool printed {"cmd": "psql \"host=... sslmode=require\""} and exited 0 still comes back with the stray quote. Adding \\ to that class is a one-character fix for its own PR.

Minor: each JSONL line is parsed twice (is_jsonl then redact_json), about 12% on a 200k-line payload in release. The walker is still twice as fast as the flat pass on the same input, so not worth restructuring.

@ahammadnafiz
ahammadnafiz merged commit e8f416f into tryatlas:0.3.1 Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants