Skip to content

fix(server): redact the URL on a driven NET_DETAIL, and merge on what was requested - #780

Merged
divshekhar merged 5 commits into
reticlehq:mainfrom
thegoodengineer:fix/net-detail-url-redaction
Sep 7, 2026
Merged

fix(server): redact the URL on a driven NET_DETAIL, and merge on what was requested#780
divshekhar merged 5 commits into
reticlehq:mainfrom
thegoodengineer:fix/net-detail-url-redaction

Conversation

@thegoodengineer

@thegoodengineer thegoodengineer commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

What & why

buildNetworkDetail takes three fields raw from the driver: the response headers, the request body, and the URL. It redacts the first two, with a comment explaining that this is the one payload reaching the journal and the agent's context without passing through the SDK sanitizer. It passes the third through untouched.

A URL is the field an app is most likely to put a credential in without meaning to. On the drive path, all of these reached the agent transcript and the on-disk journal in cleartext, while the in-page observer redacted the identical URL through redactUrl:

https://bucket.s3.amazonaws.com/f.png?X-Amz-Signature=<sig>&X-Amz-Credential=<key>
https://app.example/reset/<single-use-token>
https://app.example/cb#access_token=<token>
https://alice:<password>@api.example/data

isSensitiveKey already recognises every one of those parameter names, and redaction.presigned.test.ts exists specifically to pin the presigned-URL case. The rule was there; this path never called it.

The second half follows from the first

mergeNetworkDetail keys on the URL, and the two sides disagreed about it exactly when it mattered. The NET_REQUEST reported ?X-Amz-Signature=%5BREDACTED%5D and the NET_DETAIL reported the signature, so they never matched. Which means the requests that carried a credential were precisely the ones that:

  • silently lost their authoritative response headers and their wire body, the two things CDP capture exists to provide
  • left a duplicate NET_DETAIL in the timeline as an unmatched event, with the credential still in it

The leak and the enrichment miss are the same bug seen from two sides, and one fix closes both.

No issue filed; found while reading the redaction path.

How it was verified

packages/server/src/input/network-detail.url.test.ts, written RED first. Six cases on the builder (query parameter, presigned upload, path-embedded token, userinfo, an ordinary URL returned byte for byte with no urlRaw, and urlRaw kept when redaction did rewrite) and one on the merge, which fails on main with two events in the timeline instead of one enriched request.

The existing network-detail.test.ts passes unchanged, and so does the full packages/browser suite, which is what pins redactUrl's behaviour and now exercises it through the core implementation.

On the shape of the fix

  • redactUrl and netUrlFields move to @reticlehq/core, beside the key rule they already call. The header of redaction.ts says these live in core because they are a property of the wire rather than of one side of it, and that they moved when the driven path began capturing bodies raw. This is the same migration, one function later. packages/browser/src/observers/network-redact.ts re-exports both, so every existing import is untouched.
  • Both take the sensitivity predicate as an argument, defaulting to the ambient rule. The browser keeps the page's own policy exactly as before; the daemon passes the session policy it already resolves per response, because it deliberately sets no ambient rule.
  • A NET_DETAIL now carries url and urlRaw on the same contract a NET_REQUEST has carried them on all along, urlRaw omitted when nothing was rewritten. withoutUrlRaw already strips it before an event is rendered to an agent, so no new field reaches a transcript.
  • The merge keys on urlForMatch. That compares what was actually requested rather than what each side was willing to display, which matters because the two policies genuinely can differ: a page may declare extra keys or exempt one, and wireRedactionKeys never carries an exemption to the daemon.

One hardening that came with the move

CodeQL flagged the fragment sweep on the first CI run, and it is right. The key was matched with an unanchored ([A-Za-z0-9_.-]+)=, which can begin at every index of a long run of key-legal characters and rescan the rest of it from each one, so it is quadratic in the length of the fragment.

That was harmless while it only ran in the page over the app's own URLs. It runs in the daemon now, over URLs read off the network stack, so the input is no longer the app's to vouch for. In other words this PR is what turned a latent shape into a reachable one, so fixing it belongs here rather than in a follow-up.

The key is now anchored to the delimiter that must precede it, which costs nothing: #, &, ? and / are already outside the key character class, so every key this matched before began right after one of them. Covered by a 60,000-character delimiter-free fragment that has to come back unchanged, with the OAuth implicit-flow and plain-anchor cases either side of it so the anchoring cannot quietly stop redacting.

A note on install-gate (windows-latest)

This job is red on the branch and green on main, so I investigated rather than asking for a re-run. I do not believe this change causes it, and here is the work either way.

It is not the SDK change. I pushed a revision with packages/browser/src/observers/network-redact.ts byte-identical to main, so the SDK module was untouched by the PR. The gate failed anyway, in the same shape. That revision is reverted, because the duplication it cost bought nothing. Worth noting the isolation was imperfect: @reticlehq/core still differed, and the SDK bundles core.

Two mechanisms measured and excluded. NET_DETAIL's payload schema is .passthrough(), so the added urlRaw key is accepted rather than rejected. And redactUrl costs 0.55 microseconds per call on the built core over representative Vite dev URLs, so redacting every response URL on the drive path is about half a millisecond per thousand responses.

What the logs show is a dev-server port that is not the app's. Across four runs the failing scaffold moves (vite-vue, vite-vue, then next-app-router and monorepo-subdir with vite-vue passing 9/0, then vite-vue again), and in every case the only failing assertion is init exits 0, because init verified against a port the app was not on. In run 3, init for next-app-router probed http://localhost:5175, which was vite-react's dev server from the scaffold before it, while its own app was on :3000 and the gate's own next assertion found the session there. That is the third cause init's message lists, "a bridge port that differs on the two sides", reported accurately.

I could not reproduce it locally: --only vite-vue passes on this branch, and one scaffold means there is no preceding scaffold to inherit a port from.

Four-for-four red against a main that is four-for-four green is a correlation I cannot fully explain, and I am not going to claim flake as if I had proved it. But nothing left in this diff has a path to init's dev-server port resolution: packages/core gains two functions and one leaf import (net.ts imports nothing, so there is no cycle or evaluation-order change), and packages/server changes only CDP response capture. If this is a known shape on that runner, say so; if you would rather I split the packages/browser re-export into its own PR so this one carries only the server fix, I will do that.

Gates run

  • pnpm lint && pnpm typecheck && pnpm test:unit
  • pnpm test:e2e
  • pnpm gate:install
  • pnpm test:e2e:desktop
  • None of the above tiers apply to this change

This touches packages/core and the observed event shape, so test:e2e is the tier and I could not run it on this machine, so I have left the box unticked. CI ran e2e, desktop-e2e, install-gate and verify on the first push of this branch and all passed; nothing in the fragment-anchoring follow-up changes an event shape.

Two unit tests fail on my machine on a clean main as well, so they are not from this change: the @reticlehq/next loader and the @reticlehq/vite-plugin CJS require probe, both 5s timeouts under parallel load on Windows, plus formatGeneratedSource's Prettier sync resolution.

Checklist

  • Every commit is signed off
  • Tests added (RED then GREEN)
  • No any, no free strings, no non-null !
  • No console.log or internal tracking codes
  • Each changed file is under the 1000-line cap
  • CHANGELOG.md updated under [Unreleased]
  • Security-affecting, and covered by a test. It only ever redacts more. urlRaw is the existing grader-only field with the existing strip on the read path, and the localhost-only, nothing-leaves-the-machine posture is untouched.

Comment thread packages/core/src/redaction.ts Fixed
@thegoodengineer
thegoodengineer force-pushed the fix/net-detail-url-redaction branch from a44a976 to cf77e32 Compare September 6, 2026 06:07
… was requested

`buildNetworkDetail` takes three fields raw from the driver: the headers,
the request body, and the URL. It redacts the first two and passes the
third through. A URL is the field an app is most likely to put a credential
in without meaning to, so a presigned upload, an OAuth callback or a
password-reset link the agent was asked to follow reached the transcript and
the on-disk journal in cleartext, on the drive path only. The in-page
observer redacts the same URL through `redactUrl`.

The second half follows from the first. `mergeNetworkDetail` keys on the URL,
and the two sides disagreed about it exactly when it mattered: a NET_REQUEST
reported `?X-Amz-Signature=[REDACTED]` and the detail reported the signature,
so they never matched. The requests carrying a credential were precisely the
ones whose authoritative response headers and wire body were dropped, and a
duplicate of each survived into the timeline with the credential in it.

So:

  - `redactUrl` and `netUrlFields` move to `@reticlehq/core`, beside the key
    rule they already call. That is where the wire contract lives, and it is
    the only way both sides can run one copy of a heuristic that must not
    drift. `packages/browser/src/observers/network-redact.ts` re-exports them,
    so every existing import is unchanged. Both take the sensitivity predicate
    as an argument now, defaulting to the ambient rule: the browser keeps the
    page's own policy, and the daemon passes the session policy it already
    resolves per response, because it deliberately has no ambient rule.
  - A NET_DETAIL now carries `url` and `urlRaw` on the same contract a
    NET_REQUEST has carried them on all along, `urlRaw` omitted when nothing
    was rewritten. `withoutUrlRaw` already strips it before an event is
    rendered to an agent.
  - The merge keys on `urlForMatch`, so the comparison is over what was
    actually requested rather than over what each side was willing to display.
    That is robust to the two policies differing, which they can: the page may
    declare extra keys or exempt one, and the daemon never honours an exemption.
One hardening that came with the move. The fragment sweep matched its key with
an unanchored `([A-Za-z0-9_.-]+)=`, which can begin at every index of a long
run of key-legal characters and rescan the rest of it from each one, quadratic
in the length of the fragment. That was harmless while it only ran in the page
over the app's own URLs. It runs in the daemon now, over URLs read off the
network stack, so the input is no longer the app's to vouch for, and CodeQL
flags it as polynomial on uncontrolled data. The key is now anchored to the
delimiter that must precede it, which costs nothing: `#`, `&`, `?` and `/` are
already outside the key character class, so every key this matched before began
right after one of them. Covered by a 60,000-character delimiter-free fragment
that returns unchanged, plus the OAuth and plain-anchor cases either side of it.

Signed-off-by: Abhijeet Sharma <meetabhijeet05@gmail.com>
@thegoodengineer
thegoodengineer force-pushed the fix/net-detail-url-redaction branch from cf77e32 to 4a96da1 Compare September 6, 2026 07:31
@thegoodengineer

Copy link
Copy Markdown
Contributor Author

install-gate (windows-latest) is red on this branch three runs in a row, so I dug into it rather than just asking for a re-run. I do not think it is this change, and here is the evidence either way so you can judge.

The failing scaffold moves between runs. Run 1 and run 2: vite-vue. Run 3: next-app-router and monorepo-subdir, with vite-vue passing 9/0. A deterministic defect in a diff does not wander like that.

What run 3 actually shows is a port from the previous scaffold. Reading the per-scaffold lines in order:

──────── vite-react ────────
   the app boots  — :4820
   a session appears and can answer a state question  — http://localhost:5175/
──────── next-app-router ────────
   The SDK IS in the page at http://localhost:5175 and never dialled the bridge
   the app boots  — :4824
   a session appears and can answer a state question  — http://localhost:3000/

init for the Next app probed http://localhost:5175, which is vite-react's dev server from the scaffold before it. The Next app was on :3000, and the gate's own later assertion finds the session there without trouble. So the only thing that failed is init exits 0, because init verified against a port that was never this app's. That is the third cause its own message lists, "a bridge port that differs on the two sides", and it is reported accurately.

Why I could not reproduce it locally. node apps/e2e/install-gate.mjs --only vite-vue passes on this branch, and main fails it here for an unrelated environment reason (no agent CLI for the drive step, so init exits 1 at that step instead). Running a single scaffold means there is no preceding scaffold to inherit a port from, which is consistent with the above.

Two mechanisms I checked and ruled out, in case they are worth knowing:

  • Not the added urlRaw field. event-payloads.ts gives NET_DETAIL a .passthrough() schema, so an extra key is accepted.
  • Not the cost of redacting every response URL on the drive path. Measured on the built core over representative Vite dev URLs, redactUrl is 0.55 microseconds per call, so 1,000 responses is about half a millisecond.

install-gate (ubuntu-latest) passes on this branch, as do e2e, desktop-e2e, verify, bench, multi-agent and the rest. main passes the Windows job, so I am not claiming the gate is broadly broken, and three-for-three against that is a correlation I cannot fully explain. If you would rather I split the packages/browser re-export out to isolate it, or if this is a known shape of flake on that runner, say which and I will follow it.

@thegoodengineer
thegoodengineer force-pushed the fix/net-detail-url-redaction branch 2 times, most recently from e845ca2 to 4a96da1 Compare September 6, 2026 09:51
@divshekhar
divshekhar enabled auto-merge (squash) September 6, 2026 19:28
@divshekhar
divshekhar merged commit e17f299 into reticlehq:main Sep 7, 2026
20 checks passed
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