An approval screen can show staging while the executor reads production from exactly the same JSON text:
{
"environment": "staging",
"environment": "production"
}
That happens if the preview keeps the first occurrence of a member and the executor keeps the last. Both values may be valid environments. A schema check on each decoded object can therefore pass while the human approves a different target from the one the executor uses.
I would reject duplicate member names before tool arguments reach the approval screen. Letting two components resolve the ambiguity independently creates a permission boundary that depends on parser behaviour.
The example below is a synthetic reproduction, not a claim that Claude Code, MCP or a particular SDK uses a first-wins preview. It demonstrates a failure mode to test wherever your application accepts raw JSON and interprets it more than once.
JSON syntax does not settle the interpretation
RFC 8259 section 4 says object member names SHOULD be unique. It also describes differing behaviour when names repeat: some implementations keep the last pair, others reject the object, and some expose all pairs. Treating duplicate rejection as an application requirement is stronger and clearer than assuming every JSON parser already enforces it.
Python’s JSON documentation explicitly states that its default decoder keeps the last name-value pair. Its example decodes three occurrences of x into one dictionary entry. The earlier occurrences are gone by the time ordinary dictionary validation begins.
For the payload above, a first-wins decoder produces:
{"environment": "staging"}
Python’s default json.loads produces:
{"environment": "production"}
Suppose your schema permits exactly one field, environment, with an enum containing both values. Each resulting dictionary meets that contract. The schema validator cannot discover discarded members by inspecting a dictionary that no longer contains them.
A policy check restricted to staging would reject the executor’s production value if it ran there against the executor’s interpretation. That is a useful defence. It does not excuse an approval screen that describes a different operation, and it may not catch the disagreement when the caller is permitted to use both environments but approved only one.
Run the disagreement before fixing it
Download agent-tool-duplicate-json.py, inspect it, then run:
python3 agent-tool-duplicate-json.py
python3 agent-tool-duplicate-json.py --test
python3 -O agent-tool-duplicate-json.py --test
The script uses the Python standard library. It makes no network requests, invokes no model and performs no deployment. The preview’s first-wins policy is deliberately implemented in the fixture; the executor side uses Python’s default JSON decoder.
The demonstration prints:
preview: staging
executor: production
schema: both decoded objects pass
strict decoder: Duplicate JSON member
The final line comes from a decoder that rejects the payload before an approval or execution stage can consume it. The fixture does not simulate a complete approval service. It isolates the interpretation defect so that a test cannot accidentally pass because some unrelated control blocked the operation later.
Twelve unit tests cover the disagreement, schema acceptance after lossy decoding, duplicate rejection and valid-input behaviour. They include nested objects, objects inside arrays, identical repeated values, escaped names and malformed input. The same suite runs under python3 -O; the validation does not depend on removable assert statements.
Reject names while the decoder still has the pairs
Python exposes an object_pairs_hook callback that receives the decoded pairs for each object. Use that point to detect duplicates before constructing the dictionary:
def unique_object(pairs):
result = {}
for name, value in pairs:
if name in result:
raise ValueError("Duplicate JSON member")
result[name] = value
return result
The fixture passes this callback to json.loads. It also rejects Python’s accepted non-JSON constants such as NaN, and requires an object at the root because this example’s tool-argument contract requires one. Those are separate validation choices. Duplicate rejection alone does not establish a complete safe-input policy.
The callback runs for nested objects too. Checking only the outer object would miss a repeated environment inside target, or repeated identifiers inside an array of operations. A repeated name in two separate objects is allowed: {"items":[{"id":1},{"id":2}]} has no duplicate within either object.
Compare decoded names, not a regular expression over the original text. The following names decode to the same string:
{
"environment": "staging",
"\u0065nvironment": "production"
}
The fixture rejects that payload. A scan looking only for two literal occurrences of "environment" would miss it. Conversely, repeated words inside string values are not duplicate member names and should not trigger this rule.
Reject identical repeated values as well. Accepting {"x":1,"x":1} teaches producers that duplicates are supported and creates another branch in the contract. Requiring unique names gives the producer one unambiguous repair: emit the member once.
Keep one interpretation through approval and execution
Duplicate rejection belongs at the earliest application boundary that still has the original representation. If an SDK has already converted the arguments into a dictionary, a later adapter cannot prove that the original JSON contained no duplicates. Check the SDK’s documented behaviour and test the actual decoding path. If raw arguments are unavailable, record that limitation rather than claiming a downstream dictionary check validates the wire representation.
After decoding, validate the operation’s schema and resolve any application defaults before presenting the effective action for approval. Identify the actual target, not merely a friendly environment label. If staging resolves to a tenant, account and resource identifier, the approval needs to bind the resolved operation that will execute.
The earlier article on adapters defaulting to production covers a different way that the effective target can change. Duplicate names add an earlier failure: components can disagree before defaults are applied.
In one process, avoid handing the original ambiguous text to another permissive decoder after validating a separate object. Across services, define the accepted representation and require receivers to apply the same rejection and validation contract. If an approval binds a digest, specify what that digest covers and how the executor verifies it. A hash of the raw payload proves byte equality; it does not prove two parsers assigned those bytes the same meaning.
Sorting dictionary keys after a permissive parse cannot recover a discarded member. Canonicalisation is useful only after the input has passed the representation rules you intend to enforce. Changes to the approved arguments, resolved target or relevant defaults should invalidate the existing approval rather than silently reusing it.
These are application design requirements, not a claim that adding object_pairs_hook supplies identity, authorisation or tamper resistance. Execution still needs its own policy check and an approval binding appropriate to the operation.
Test every place that decodes the arguments
Start with the production versions of your ingress handler, preview renderer and execution adapter. Feed the same small corpus through each decoding boundary. Record whether each input was rejected and, for accepted input, the effective operation it produced. Use synthetic targets so the test cannot trigger real work.
Include the two environments in both orders. Put duplicates below nested keys and inside array elements. Use escaped equivalent names, identical repeated values and ordinary valid payloads. Verify that the approved and executed representations agree after defaults and target resolution, not only immediately after parsing.
If arguments arrive in streamed fragments, wait for a complete validated argument object before enabling an actionable approval. A partial preview can omit a later member. That is a reason to distinguish provisional display from an operation the user can authorise.
The fixture intentionally omits transport size limits, maximum nesting depth and operation-specific numeric rules. Set those separately in production. For example, rejecting the token Infinity does not by itself define how to handle an otherwise syntactically valid number whose magnitude exceeds your chosen numeric type. Specify the field’s accepted range rather than inheriting whatever conversion happens to produce.
Log a bounded rejection reason and a correlation identifier. Avoid copying whole rejected payloads into broad-access telemetry: tool arguments can contain secrets or personal data. A parser error should stop before side effects and should remain distinguishable from a provider failure that might enter a retry loop.
For the surrounding review and tool-governance workflow, my book Claude Code: Building Production Agents That Actually Scale on Amazon Kindle covers production controls around agent actions. The production readiness checklist is a shorter starting point for reviewing the boundary.
Add this duplicate-key corpus to one real tool path. Require rejection before approval, and verify that the executor receives no operation for any rejected case. Keep that test beside the decoder integration so a library upgrade cannot silently change the contract.