Refuse a request body that names a memory id ANYWHERE in it. 400 rather than
a silent override, for the same reason the run router 400s a client runId: a
caller pinning ids must find out, not watch its value get quietly replaced and
conclude the field works.
NESTED, not just top-level, because the shape the first real consumer sends is
nested: an agent run starts through POST /runs as
{workflowId, inputData: {...}}, so the natural body for a threaded run is
{inputData: {threadId, prompt}} — a top-level-only check would pass exactly
the request this guard exists to refuse. A legitimate payload never carries a
memory id (that is the whole doctrine: they are minted server-side and travel
through requestContext, never a body), so refusing the name at any depth costs
nothing real and closes the shape the leak actually arrives in.
undefined is refused along with a value — a body carrying the KEY is a
caller that believes it may choose, and the day someone writes
threadId: maybeUndefined the tolerant reading is a leak waiting on one
truthiness bug. Non-object bodies name no field and pass; body SHAPE is each
route's own schema business.
The walk is ITERATIVE and UNBOUNDED in depth. A depth cap would be a bypass
with a number on it — nest one level past it and the id sails through the
check — and depth is the attacker's choice, not the route's. Recursion is what
a hostile 10k-deep body would actually break (the stack), so the fix is to not
recurse: an explicit worklist costs heap proportional to a body JSON.parse has
already materialized. A seen set keeps a cyclic in-process object (never a
parsed body, which cannot cycle) from looping forever.
Covers what a JSON body can express. A Map's entries and a non-enumerable
property are invisible to it — neither survives JSON.parse, so no HTTP caller
can reach them; a route handing this a hand-built in-process object is outside
the contract.
Refuse a request body that names a memory id ANYWHERE in it. 400 rather than a silent override, for the same reason the run router 400s a client runId: a caller pinning ids must find out, not watch its value get quietly replaced and conclude the field works.
NESTED, not just top-level, because the shape the first real consumer sends is nested: an agent run starts through
POST /runsas{workflowId, inputData: {...}}, so the natural body for a threaded run is{inputData: {threadId, prompt}}— a top-level-only check would pass exactly the request this guard exists to refuse. A legitimate payload never carries a memory id (that is the whole doctrine: they are minted server-side and travel through requestContext, never a body), so refusing the name at any depth costs nothing real and closes the shape the leak actually arrives in.undefinedis refused along with a value — a body carrying the KEY is a caller that believes it may choose, and the day someone writesthreadId: maybeUndefinedthe tolerant reading is a leak waiting on one truthiness bug. Non-object bodies name no field and pass; body SHAPE is each route's own schema business.The walk is ITERATIVE and UNBOUNDED in depth. A depth cap would be a bypass with a number on it — nest one level past it and the id sails through the check — and depth is the attacker's choice, not the route's. Recursion is what a hostile 10k-deep body would actually break (the stack), so the fix is to not recurse: an explicit worklist costs heap proportional to a body JSON.parse has already materialized. A
seenset keeps a cyclic in-process object (never a parsed body, which cannot cycle) from looping forever.Covers what a JSON body can express. A
Map's entries and a non-enumerable property are invisible to it — neither survives JSON.parse, so no HTTP caller can reach them; a route handing this a hand-built in-process object is outside the contract.