[GH-ISSUE #6500] [Bug] redacted network bodies exceed the selected retained-byte limit #3897

Closed
opened 2026-10-31 00:45:02 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 23, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/6500

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Process capture or runtime observation

Observed behavior

Real Chromium captures through both the public capture-browser-scenario CLI and stdio MCP capture_browser_scenario return a 40-byte retained POST request body and a 40-byte retained response body under max_network_body_bytes: 4. The input body is aaaa; the scenario explicitly declares the synthetic environment value a as sensitive. Both phases report state: "captured", bytes: 40, redacted: true, and decoded content [REDACTED][REDACTED][REDACTED][REDACTED]. The capture records the selected 4-byte limit. The byte count and digest correctly describe the retained content, but that content exceeds the selected retained-body bound.

Reproduction

Executed one run of each of six cases through each adapter, exercising both body phases in every case: twelve captures in total. This uses an owned disposable browser launched by REA and an HTTP server on loopback. There is no provider substitution, existing browser profile, account, external site, or real secret. The driver constructs the child environment from scratch and keeps HOME, TMPDIR, XDG and DSH state under disposable storage.

From the source checkout at the revision below, with its locked dependencies and compiled runtime available, use the pinned Node.js version. Export BROWSER as the absolute path to an existing Chromium executable. Save and run this complete driver; it writes the raw adapter replies and a reduced summary.json into WORK. The assertions characterize the current behavior, including the defect, rather than assert a repaired implementation.

: "${BROWSER:?Export BROWSER as the absolute path to an existing Chromium executable}"
export BROWSER
export WORK="$(mktemp -d)"
cat > "$WORK/repro.mjs" <<'JS'
import assert from 'node:assert/strict';
import {createServer} from 'node:http';
import {execFile} from 'node:child_process';
import {promisify} from 'node:util';
import {mkdir,writeFile,readdir} from 'node:fs/promises';
import {createRequire} from 'node:module';
import {pathToFileURL} from 'node:url';
import {join,dirname} from 'node:path';
const root=process.cwd(), work=process.env.WORK, browser=process.env.BROWSER;
assert.ok(work && browser, 'Set WORK to disposable storage and BROWSER to existing Chromium executable');
const execute=promisify(execFile), require=createRequire(join(root,'package.json'));
const {Client}=await import(pathToFileURL(require.resolve('@modelcontextprotocol/client')));
const {StdioClientTransport}=await import(pathToFileURL(require.resolve('@modelcontextprotocol/client/stdio')));
for(const dir of ['home','tmp','config','cache','data','state','dsh','agents']) await mkdir(join(work,dir),{recursive:true});
const env={PATH:dirname(process.execPath)+':/usr/bin:/bin',HOME:join(work,'home'),TMPDIR:join(work,'tmp'),XDG_CONFIG_HOME:join(work,'config'),XDG_CACHE_HOME:join(work,'cache'),XDG_DATA_HOME:join(work,'data'),XDG_STATE_HOME:join(work,'state'),DSH_HOME:join(work,'dsh'),DSH_AGENTS_HOME:join(work,'agents'),REA_LOG_LEVEL:'silent',FIXTURE_SECRET:'a'};
let received=[];
const server=createServer((req,res)=>{
 if(req.url==='/body'){
  const chunks=[];req.on('data',b=>chunks.push(b));req.on('end',()=>{const body=Buffer.concat(chunks);received.push(body.toString());res.setHeader('Content-Type','text/plain');res.end(body);});return;
 }
 const body=new URL(req.url,'http://fixture.invalid').searchParams.get('body')??'bbbb';
 res.setHeader('Content-Type','text/html');
 res.end('<!doctype html><title>Fixture</title><link rel="icon" href="data:,"><button id="send">Send</button><script>document.querySelector("#send").onclick=async()=>{const r=await fetch("/body",{method:"POST",headers:{"Content-Type":"text/plain"},body:'+JSON.stringify(body)+'});await r.text();document.querySelector("#send").textContent="Done";};</script>');
});
await new Promise(r=>server.listen(0,'127.0.0.1',r));
const origin='http://127.0.0.1:'+server.address().port;
const cases=[
 {label:'expanded',body:'aaaa',limit:4,allocation:1000000},
 {label:'plain-at-limit',body:'bbbb',limit:4,allocation:1000000},
 {label:'plain-over-limit',body:'bbbbb',limit:4,allocation:1000000},
 {label:'redacted-at-limit',body:'aaaa',limit:40,allocation:1000000},
 {label:'low-allocation-expanded',body:'aaaa',limit:4,allocation:100},
 {label:'low-allocation-plain',body:'bbbb',limit:4,allocation:100}
];
let client,transport;
const summaries=[];
try{
 console.log(JSON.stringify({node:process.version,platform:process.platform,arch:process.arch,browser:(await execute(browser,['--version'],{env})).stdout.trim()}));
 for(const adapter of ['CLI','MCP']){
  if(adapter==='MCP'){
   transport=new StdioClientTransport({command:process.execPath,args:[join(root,'scripts/rea.mjs'),'mcp'],env,stderr:'pipe'});
   client=new Client({name:'synthetic-body-budget-verifier',version:'1'});
   await client.connect(transport);
  }
  for(const c of cases){
   const scenario={browser:{mode:'launch',executable_path:browser,headless:true},start_url:{url:origin+'/?body='+c.body},actions:[{step_id:'send',action:'click',locator:{kind:'css',selector:'#send'}},{step_id:'done',action:'wait_for',locator:{kind:'role',role:'button',name:'Done'},state:'visible'}],secrets:[{secret_id:'declared',environment_variable:'FIXTURE_SECRET'}],capture:{events:['network'],network:{request_body:true,response_body:true,header_values:false},limits:{max_network_body_bytes:c.limit,max_network_body_allocation_bytes:c.allocation}}};
   let result;
   if(adapter==='CLI'){
    const {stdout,stderr}=await execute(process.execPath,[join(root,'scripts/rea.mjs'),'capture-browser-scenario',JSON.stringify(scenario),'--json'],{cwd:root,env,timeout:45000,maxBuffer:16*1024*1024});
    assert.equal(stderr,'');result=JSON.parse(stdout);
   }else{result=await client.callTool({name:'capture_browser_scenario',arguments:scenario});assert.notEqual(result.isError,true);}
   await writeFile(join(work,adapter+'-'+c.label+'.json'),JSON.stringify(result,null,2));
   const capture=adapter==='CLI'?result.normalized_result:(result.structuredContent?.normalized_result??result.structuredContent??JSON.parse(result.content.find(x=>x.type==='text').text).normalized_result);
   assert.ok(capture?.events,JSON.stringify(result).slice(0,3000));
   const transaction=capture.events.items.find(e=>e.kind==='request'&&e.method==='POST'&&e.url.url.endsWith('/body')).transaction_id;
   const bodies=capture.events.items.filter(e=>e.kind==='network-content'&&e.transaction_id===transaction).map(e=>({phase:e.phase,...e.body,decoded:e.body.state==='captured'?Buffer.from(e.body.content,'base64').toString():null}));
   assert.equal(bodies.length,2);assert.equal(capture.browser.cleanup,'terminated-owned-process');
   const summary={adapter,label:c.label,limit:capture.scenario.limits.max_network_body_bytes,bodies,omissions:capture.events.omissions,cleanup:capture.browser.cleanup};
   summaries.push(summary);console.log(JSON.stringify(summary));
  }
 }
 assert.equal(received.length,12);
 for(const adapter of ['CLI','MCP']){
  const get=label=>summaries.find(s=>s.adapter===adapter&&s.label===label);
  for(const b of get('expanded').bodies){assert.equal(b.state,'captured');assert.equal(b.bytes,40);assert.equal(b.decoded,'[REDACTED]'.repeat(4));}
  for(const b of get('plain-at-limit').bodies){assert.equal(b.state,'captured');assert.equal(b.bytes,4);assert.equal(b.decoded,'bbbb');}
  for(const b of get('plain-over-limit').bodies){assert.equal(b.state,'unavailable');assert.equal(b.reason,'body-size-limit');assert.equal(b.observed_bytes,5);assert.equal(b.limit_bytes,4);}
  for(const b of get('redacted-at-limit').bodies){assert.equal(b.state,'captured');assert.equal(b.bytes,40);}
  for(const label of ['low-allocation-expanded','low-allocation-plain']) for(const b of get(label).bodies){assert.equal(b.state,'unavailable');assert.equal(b.reason,'body-allocation-limit');}
 }
 await writeFile(join(work,'summary.json'),JSON.stringify(summaries,null,2));
 console.log(JSON.stringify({verified:true,requests:received.length,residualTempEntries:await readdir(join(work,'tmp'))}));
}finally{
 await client?.close();await transport?.close();await new Promise(r=>server.close(r));
}
JS
node "$WORK/repro.mjs"
# Inspect summary.json and the adapter replies under WORK before cleanup.
rm -r "$WORK"

The fixture echoes the POST bytes, then the page waits for the response to finish before the scenario completes. The driver finds the POST request's transaction_id and inspects its two content events. The navigation document is larger than the deliberately tiny body limit and has its own explicit unavailable response body; it is not the transaction used to establish this defect.

Expected behavior

max_network_body_bytes must bound the retained decoded body representation after declared-secret redaction. Retained body content must stay within the selected byte count, or the body must be explicitly unavailable with the appropriate size/allocation reason and observed and allowed values. Fitting bodies must remain capturable when the other selected budgets admit them.

The owning capture contract distinguishes body bytes from the separate redaction/base64 allocation estimate and says the body-byte limit bounds retained copies after Playwright has materialized the input. The environment guide states that a scenario keeps at most max_network_body_bytes of each body. Neither promise bounds the initial Playwright API materialization or exact process memory. The generic allocation-estimate wording for event budgets does not make 40 retained decoded body bytes fit a 4-byte body limit.

Impact and scope

P2: callers can receive more retained body bytes than their selected per-phase limit permits. The verified scope is both request and response capture when replacement markers expand a declared value and the separate allocation/event budgets admit the result. The body contents remain correctly redacted and self-consistent, and the other budgets still apply. No crash, body corruption, data loss, real-secret exposure or security-boundary crossing was observed. Default-limit failure, downstream replay effects, other browsers/platforms and natural frequency were not established.

No preserving workaround was demonstrated. Raising the limit from 4 to 40 captures these bodies within the new bound, but changes the caller's original requirement. Lowering max_network_body_allocation_bytes to 100 rejects the expanded body, but also rejects the otherwise fitting unredacted 4-byte body. Both inputs receive the same 864-byte allocation estimate, so this threshold cannot distinguish them. That setting is an option for callers willing to lose fitting body evidence, rather than preservation of the original selected capture.

REA version

b395190e42 (prepared source checkout; package manifest 6.7.0; its compiled runtime was executed).

Runtime and platform

Node.js v24.18.0; macOS 27.0.1, Darwin 27.0.0 arm64; Google Chrome for Testing 145.0.7632.6; locked playwright-core 1.58.2 with its matching cached Chromium revision 1208.

Target and artifact context

Synthetic HTML page and echo endpoint on 127.0.0.1. The page sends one text/plain POST per capture, with network events and request/response body capture selected. FIXTURE_SECRET=a is a declared synthetic value. The driver runs six independent owned launches through each adapter and compares the correlated POST content events.

Interface

CLI

Earliest failure stage

Capture or observation

Relevant output or evidence

  • Expected source: docs/browser-scenario-contract.md:246-255 distinguishes body bytes from allocation estimates, limits subsequent retained copies and requires an explicit over-limit state; docs/agent-environments.md:45-54 states the per-body maximum and separate allocation behavior. src/domain/browserNetworkEvidence.ts:63-105 checks the byte count and digest against the decoded retained representation after redaction.
  • Failure source: The real CLI/MCP driver above returned both POST phases as captured with 40 decoded retained bytes and a selected body limit of 4. src/browser/network/PlaywrightNetworkContent.ts:131-179 compares the observed buffer length before redaction; :179-194 then retains the redacted buffer and reports its length. Both phases use that owner through src/browser/network/PlaywrightScenarioNetwork.ts:182-199 and :578-595.
  • Evidence provenance: observed
  • Local verification: reproduced
  • Reproduction completeness: complete

All six CLI subprocesses exited 0 with empty stderr, and all six MCP calls returned successful tool results. For each adapter, request and response observations agreed:

case                       input   body limit  allocation limit  result in each phase
expanded                   aaaa    4           1000000           captured, bytes 40, redacted true
plain-at-limit             bbbb    4           1000000           captured, bytes 4, redacted false
plain-over-limit           bbbbb   4           1000000           unavailable, body-size-limit, observed 5, allowed 4
redacted-at-limit          aaaa    40          1000000           captured, bytes 40, redacted true
low-allocation-expanded    aaaa    4           100               unavailable, body-allocation-limit, observed 864, allowed 100
low-allocation-plain       bbbb    4           100               unavailable, body-allocation-limit, observed 864, allowed 100

Each offending POST body had this retained identity:

{"state":"captured","encoding":"base64","content":"W1JFREFDVEVEXVtSRURBQ1RFRF1bUkVEQUNURURdW1JFREFDVEVEXQ==","bytes":40,"sha256":"b67e3688f5ceb67b314425b3df1df19593a9dbcce19557ff3778d288a0fb717a","redacted":true}

Both browser-exposed-request-bytes and playwright-response-body-bytes representations were observed. Every capture reported cleanup: "terminated-owned-process" and empty event-omission inventories. The fixture received twelve complete POSTs; the isolated temporary directory contained no remaining provider profile entries after completion. This was a public-adapter and real-browser reproduction, not only handler or fake-boundary evidence.

Restoration check

Proposed acceptance check; no repair was applied. Repeat the 4-byte expanded-body case through both adapters. Neither POST phase may be captured with retained decoded bytes above the recorded 4-byte bound. A fitting retained result or explicit unavailable size/allocation state with observed and allowed values must replace the overage. Preserve the plain 4-byte captured control, the redacted 40-byte captured control at limit 40, and the explicit plain 5-byte refusal at limit 4. Preserve declared-value redaction and content identity checks. Rejecting all selected bodies, weakening redaction or silently raising the selected limit would not restore this contract.

Analysis or suspected cause

Source-derived explanation: admission compares the pre-redaction buffer length with the selected body-byte limit, while the retained count is measured after replacement. The separate allocation preflight accounts conservatively for potential expansion but does not enforce the selected retained-byte maximum. The production output schema verifies body content identity and transaction links without comparing a captured body's byte count with the capture's recorded limit. Runtime controls are consistent with this explanation.

Additional context

Searched the supplied issue inventory and live open/closed issues using body-limit, retained-body, redaction, allocation and browser terms. #6474 concerns discarded POST data overcharging the observe-web-execution request-metadata budget. #5892 concerns phantom unfinished-event omissions under event admission pressure. Those have different failure/restoration signals; this reproduction has no event omissions.

The repair linked from closed #3127 introduced caller-selectable capture budgets and the retained-copy contract; the baseline contains that repair, including its pre-redaction length comparison. Merged PR #1590 repairs secret spellings introduced by markers. Safe marker selection works here, while its retained bytes can expand beyond the selected bound. Neither repair restores this candidate's signal. No matching report was found in the bounded searches. The first affected release and a known-good build are unknown.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: Real CLI and MCP captures retain 40 decoded body bytes in both POST phases under a recorded 4-byte body maximum. This violates a bounded retained-output contract on affected declared-value inputs and selected limits. Captured content remains correctly redacted and self-consistent, with other budgets still effective; no loss of the primary body evidence or severe runtime failure was observed.
  • Workaround status: none
  • Workaround basis: No workaround preserving the original selected capture was established. Raising the body maximum changes the required bound. The tested lower allocation setting rejects both the over-limit redacted body and the otherwise fitting plain body, which have the same 864-byte estimate; it drops requested fitting evidence instead of preserving that result.
Originally created by @coygeek on GitHub (Oct 23, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6500 ## Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ## Area Process capture or runtime observation ## Observed behavior Real Chromium captures through both the public `capture-browser-scenario` CLI and stdio MCP `capture_browser_scenario` return a 40-byte retained POST request body and a 40-byte retained response body under `max_network_body_bytes: 4`. The input body is `aaaa`; the scenario explicitly declares the synthetic environment value `a` as sensitive. Both phases report `state: "captured"`, `bytes: 40`, `redacted: true`, and decoded content `[REDACTED][REDACTED][REDACTED][REDACTED]`. The capture records the selected 4-byte limit. The byte count and digest correctly describe the retained content, but that content exceeds the selected retained-body bound. ## Reproduction Executed one run of each of six cases through each adapter, exercising both body phases in every case: twelve captures in total. This uses an owned disposable browser launched by REA and an HTTP server on loopback. There is no provider substitution, existing browser profile, account, external site, or real secret. The driver constructs the child environment from scratch and keeps HOME, TMPDIR, XDG and DSH state under disposable storage. From the source checkout at the revision below, with its locked dependencies and compiled runtime available, use the pinned Node.js version. Export `BROWSER` as the absolute path to an existing Chromium executable. Save and run this complete driver; it writes the raw adapter replies and a reduced `summary.json` into `WORK`. The assertions characterize the current behavior, including the defect, rather than assert a repaired implementation. ```sh : "${BROWSER:?Export BROWSER as the absolute path to an existing Chromium executable}" export BROWSER export WORK="$(mktemp -d)" cat > "$WORK/repro.mjs" <<'JS' import assert from 'node:assert/strict'; import {createServer} from 'node:http'; import {execFile} from 'node:child_process'; import {promisify} from 'node:util'; import {mkdir,writeFile,readdir} from 'node:fs/promises'; import {createRequire} from 'node:module'; import {pathToFileURL} from 'node:url'; import {join,dirname} from 'node:path'; const root=process.cwd(), work=process.env.WORK, browser=process.env.BROWSER; assert.ok(work && browser, 'Set WORK to disposable storage and BROWSER to existing Chromium executable'); const execute=promisify(execFile), require=createRequire(join(root,'package.json')); const {Client}=await import(pathToFileURL(require.resolve('@modelcontextprotocol/client'))); const {StdioClientTransport}=await import(pathToFileURL(require.resolve('@modelcontextprotocol/client/stdio'))); for(const dir of ['home','tmp','config','cache','data','state','dsh','agents']) await mkdir(join(work,dir),{recursive:true}); const env={PATH:dirname(process.execPath)+':/usr/bin:/bin',HOME:join(work,'home'),TMPDIR:join(work,'tmp'),XDG_CONFIG_HOME:join(work,'config'),XDG_CACHE_HOME:join(work,'cache'),XDG_DATA_HOME:join(work,'data'),XDG_STATE_HOME:join(work,'state'),DSH_HOME:join(work,'dsh'),DSH_AGENTS_HOME:join(work,'agents'),REA_LOG_LEVEL:'silent',FIXTURE_SECRET:'a'}; let received=[]; const server=createServer((req,res)=>{ if(req.url==='/body'){ const chunks=[];req.on('data',b=>chunks.push(b));req.on('end',()=>{const body=Buffer.concat(chunks);received.push(body.toString());res.setHeader('Content-Type','text/plain');res.end(body);});return; } const body=new URL(req.url,'http://fixture.invalid').searchParams.get('body')??'bbbb'; res.setHeader('Content-Type','text/html'); res.end('<!doctype html><title>Fixture</title><link rel="icon" href="data:,"><button id="send">Send</button><script>document.querySelector("#send").onclick=async()=>{const r=await fetch("/body",{method:"POST",headers:{"Content-Type":"text/plain"},body:'+JSON.stringify(body)+'});await r.text();document.querySelector("#send").textContent="Done";};</script>'); }); await new Promise(r=>server.listen(0,'127.0.0.1',r)); const origin='http://127.0.0.1:'+server.address().port; const cases=[ {label:'expanded',body:'aaaa',limit:4,allocation:1000000}, {label:'plain-at-limit',body:'bbbb',limit:4,allocation:1000000}, {label:'plain-over-limit',body:'bbbbb',limit:4,allocation:1000000}, {label:'redacted-at-limit',body:'aaaa',limit:40,allocation:1000000}, {label:'low-allocation-expanded',body:'aaaa',limit:4,allocation:100}, {label:'low-allocation-plain',body:'bbbb',limit:4,allocation:100} ]; let client,transport; const summaries=[]; try{ console.log(JSON.stringify({node:process.version,platform:process.platform,arch:process.arch,browser:(await execute(browser,['--version'],{env})).stdout.trim()})); for(const adapter of ['CLI','MCP']){ if(adapter==='MCP'){ transport=new StdioClientTransport({command:process.execPath,args:[join(root,'scripts/rea.mjs'),'mcp'],env,stderr:'pipe'}); client=new Client({name:'synthetic-body-budget-verifier',version:'1'}); await client.connect(transport); } for(const c of cases){ const scenario={browser:{mode:'launch',executable_path:browser,headless:true},start_url:{url:origin+'/?body='+c.body},actions:[{step_id:'send',action:'click',locator:{kind:'css',selector:'#send'}},{step_id:'done',action:'wait_for',locator:{kind:'role',role:'button',name:'Done'},state:'visible'}],secrets:[{secret_id:'declared',environment_variable:'FIXTURE_SECRET'}],capture:{events:['network'],network:{request_body:true,response_body:true,header_values:false},limits:{max_network_body_bytes:c.limit,max_network_body_allocation_bytes:c.allocation}}}; let result; if(adapter==='CLI'){ const {stdout,stderr}=await execute(process.execPath,[join(root,'scripts/rea.mjs'),'capture-browser-scenario',JSON.stringify(scenario),'--json'],{cwd:root,env,timeout:45000,maxBuffer:16*1024*1024}); assert.equal(stderr,'');result=JSON.parse(stdout); }else{result=await client.callTool({name:'capture_browser_scenario',arguments:scenario});assert.notEqual(result.isError,true);} await writeFile(join(work,adapter+'-'+c.label+'.json'),JSON.stringify(result,null,2)); const capture=adapter==='CLI'?result.normalized_result:(result.structuredContent?.normalized_result??result.structuredContent??JSON.parse(result.content.find(x=>x.type==='text').text).normalized_result); assert.ok(capture?.events,JSON.stringify(result).slice(0,3000)); const transaction=capture.events.items.find(e=>e.kind==='request'&&e.method==='POST'&&e.url.url.endsWith('/body')).transaction_id; const bodies=capture.events.items.filter(e=>e.kind==='network-content'&&e.transaction_id===transaction).map(e=>({phase:e.phase,...e.body,decoded:e.body.state==='captured'?Buffer.from(e.body.content,'base64').toString():null})); assert.equal(bodies.length,2);assert.equal(capture.browser.cleanup,'terminated-owned-process'); const summary={adapter,label:c.label,limit:capture.scenario.limits.max_network_body_bytes,bodies,omissions:capture.events.omissions,cleanup:capture.browser.cleanup}; summaries.push(summary);console.log(JSON.stringify(summary)); } } assert.equal(received.length,12); for(const adapter of ['CLI','MCP']){ const get=label=>summaries.find(s=>s.adapter===adapter&&s.label===label); for(const b of get('expanded').bodies){assert.equal(b.state,'captured');assert.equal(b.bytes,40);assert.equal(b.decoded,'[REDACTED]'.repeat(4));} for(const b of get('plain-at-limit').bodies){assert.equal(b.state,'captured');assert.equal(b.bytes,4);assert.equal(b.decoded,'bbbb');} for(const b of get('plain-over-limit').bodies){assert.equal(b.state,'unavailable');assert.equal(b.reason,'body-size-limit');assert.equal(b.observed_bytes,5);assert.equal(b.limit_bytes,4);} for(const b of get('redacted-at-limit').bodies){assert.equal(b.state,'captured');assert.equal(b.bytes,40);} for(const label of ['low-allocation-expanded','low-allocation-plain']) for(const b of get(label).bodies){assert.equal(b.state,'unavailable');assert.equal(b.reason,'body-allocation-limit');} } await writeFile(join(work,'summary.json'),JSON.stringify(summaries,null,2)); console.log(JSON.stringify({verified:true,requests:received.length,residualTempEntries:await readdir(join(work,'tmp'))})); }finally{ await client?.close();await transport?.close();await new Promise(r=>server.close(r)); } JS node "$WORK/repro.mjs" # Inspect summary.json and the adapter replies under WORK before cleanup. rm -r "$WORK" ``` The fixture echoes the POST bytes, then the page waits for the response to finish before the scenario completes. The driver finds the POST request's `transaction_id` and inspects its two content events. The navigation document is larger than the deliberately tiny body limit and has its own explicit unavailable response body; it is not the transaction used to establish this defect. ## Expected behavior `max_network_body_bytes` must bound the retained decoded body representation after declared-secret redaction. Retained body content must stay within the selected byte count, or the body must be explicitly unavailable with the appropriate size/allocation reason and observed and allowed values. Fitting bodies must remain capturable when the other selected budgets admit them. The owning capture contract distinguishes body bytes from the separate redaction/base64 allocation estimate and says the body-byte limit bounds retained copies after Playwright has materialized the input. The environment guide states that a scenario keeps at most `max_network_body_bytes` of each body. Neither promise bounds the initial Playwright API materialization or exact process memory. The generic allocation-estimate wording for event budgets does not make 40 retained decoded body bytes fit a 4-byte body limit. ## Impact and scope P2: callers can receive more retained body bytes than their selected per-phase limit permits. The verified scope is both request and response capture when replacement markers expand a declared value and the separate allocation/event budgets admit the result. The body contents remain correctly redacted and self-consistent, and the other budgets still apply. No crash, body corruption, data loss, real-secret exposure or security-boundary crossing was observed. Default-limit failure, downstream replay effects, other browsers/platforms and natural frequency were not established. No preserving workaround was demonstrated. Raising the limit from 4 to 40 captures these bodies within the new bound, but changes the caller's original requirement. Lowering `max_network_body_allocation_bytes` to 100 rejects the expanded body, but also rejects the otherwise fitting unredacted 4-byte body. Both inputs receive the same 864-byte allocation estimate, so this threshold cannot distinguish them. That setting is an option for callers willing to lose fitting body evidence, rather than preservation of the original selected capture. ## REA version b395190e4273b2542a599aa2cdce61107f3a7ebc (prepared source checkout; package manifest 6.7.0; its compiled runtime was executed). ## Runtime and platform Node.js v24.18.0; macOS 27.0.1, Darwin 27.0.0 arm64; Google Chrome for Testing 145.0.7632.6; locked playwright-core 1.58.2 with its matching cached Chromium revision 1208. ## Target and artifact context Synthetic HTML page and echo endpoint on 127.0.0.1. The page sends one text/plain POST per capture, with network events and request/response body capture selected. `FIXTURE_SECRET=a` is a declared synthetic value. The driver runs six independent owned launches through each adapter and compares the correlated POST content events. ## Interface CLI ## Earliest failure stage Capture or observation ## Relevant output or evidence - Expected source: `docs/browser-scenario-contract.md:246-255` distinguishes body bytes from allocation estimates, limits subsequent retained copies and requires an explicit over-limit state; `docs/agent-environments.md:45-54` states the per-body maximum and separate allocation behavior. `src/domain/browserNetworkEvidence.ts:63-105` checks the byte count and digest against the decoded retained representation after redaction. - Failure source: The real CLI/MCP driver above returned both POST phases as captured with 40 decoded retained bytes and a selected body limit of 4. `src/browser/network/PlaywrightNetworkContent.ts:131-179` compares the observed buffer length before redaction; `:179-194` then retains the redacted buffer and reports its length. Both phases use that owner through `src/browser/network/PlaywrightScenarioNetwork.ts:182-199` and `:578-595`. - Evidence provenance: observed - Local verification: reproduced - Reproduction completeness: complete All six CLI subprocesses exited 0 with empty stderr, and all six MCP calls returned successful tool results. For each adapter, request and response observations agreed: ```text case input body limit allocation limit result in each phase expanded aaaa 4 1000000 captured, bytes 40, redacted true plain-at-limit bbbb 4 1000000 captured, bytes 4, redacted false plain-over-limit bbbbb 4 1000000 unavailable, body-size-limit, observed 5, allowed 4 redacted-at-limit aaaa 40 1000000 captured, bytes 40, redacted true low-allocation-expanded aaaa 4 100 unavailable, body-allocation-limit, observed 864, allowed 100 low-allocation-plain bbbb 4 100 unavailable, body-allocation-limit, observed 864, allowed 100 ``` Each offending POST body had this retained identity: ```json {"state":"captured","encoding":"base64","content":"W1JFREFDVEVEXVtSRURBQ1RFRF1bUkVEQUNURURdW1JFREFDVEVEXQ==","bytes":40,"sha256":"b67e3688f5ceb67b314425b3df1df19593a9dbcce19557ff3778d288a0fb717a","redacted":true} ``` Both `browser-exposed-request-bytes` and `playwright-response-body-bytes` representations were observed. Every capture reported `cleanup: "terminated-owned-process"` and empty event-omission inventories. The fixture received twelve complete POSTs; the isolated temporary directory contained no remaining provider profile entries after completion. This was a public-adapter and real-browser reproduction, not only handler or fake-boundary evidence. ### Restoration check Proposed acceptance check; no repair was applied. Repeat the 4-byte expanded-body case through both adapters. Neither POST phase may be `captured` with retained decoded bytes above the recorded 4-byte bound. A fitting retained result or explicit unavailable size/allocation state with observed and allowed values must replace the overage. Preserve the plain 4-byte captured control, the redacted 40-byte captured control at limit 40, and the explicit plain 5-byte refusal at limit 4. Preserve declared-value redaction and content identity checks. Rejecting all selected bodies, weakening redaction or silently raising the selected limit would not restore this contract. ## Analysis or suspected cause Source-derived explanation: admission compares the pre-redaction buffer length with the selected body-byte limit, while the retained count is measured after replacement. The separate allocation preflight accounts conservatively for potential expansion but does not enforce the selected retained-byte maximum. The production output schema verifies body content identity and transaction links without comparing a captured body's byte count with the capture's recorded limit. Runtime controls are consistent with this explanation. ## Additional context Searched the supplied issue inventory and live open/closed issues using body-limit, retained-body, redaction, allocation and browser terms. [#6474](https://github.com/morluto/rea/issues/6474) concerns discarded POST data overcharging the `observe-web-execution` request-metadata budget. [#5892](https://github.com/morluto/rea/issues/5892) concerns phantom unfinished-event omissions under event admission pressure. Those have different failure/restoration signals; this reproduction has no event omissions. The repair linked from closed [#3127](https://github.com/morluto/rea/issues/3127) introduced caller-selectable capture budgets and the retained-copy contract; the baseline contains that repair, including its pre-redaction length comparison. Merged [PR #1590](https://github.com/morluto/rea/pull/1590) repairs secret spellings introduced by markers. Safe marker selection works here, while its retained bytes can expand beyond the selected bound. Neither repair restores this candidate's signal. No matching report was found in the bounded searches. The first affected release and a known-good build are unknown. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: Real CLI and MCP captures retain 40 decoded body bytes in both POST phases under a recorded 4-byte body maximum. This violates a bounded retained-output contract on affected declared-value inputs and selected limits. Captured content remains correctly redacted and self-consistent, with other budgets still effective; no loss of the primary body evidence or severe runtime failure was observed. - Workaround status: none - Workaround basis: No workaround preserving the original selected capture was established. Raising the body maximum changes the required bound. The tested lower allocation setting rejects both the over-limit redacted body and the otherwise fitting plain body, which have the same 864-byte estimate; it drops requested fitting evidence instead of preserving that result.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#3897
No description provided.