HomeJournalThis post

AI Agent Tool Fuzzing from JSON Schema

Compile a bounded JSON Schema into seeded valid calls, hostile near-misses, and minimized replay receipts before an agent can use the handler.

JP
JP Casabianca
AI Engineer and Product Designer · full-stack delivery · Bogotá

AI agent tool fuzzing tests the executable boundary behind a tool schema, not the model’s ability to produce one happy-path argument object. This tutorial compiles a bounded JSON Schema subset into seeded valid cases and deliberate near-misses, then shrinks a failing tool call into a replayable receipt.

Define the tool boundary before fuzzing it

AI agent tool fuzzing starts at the executable boundary behind a tool declaration. A schema validator can reject a missing title, yet still say nothing about authorization, rate limits, side effects, or whether the handler writes a file before returning an error. Treat those as separate layers: structural validation, business invariants, policy, and effects.

The running example is a synthetic renderPoster handler. It accepts a title, a format enum, bounded dimensions, and a short palette array; it normalizes the values and returns an in-memory receipt without touching a network, filesystem, shell, or credential. That modest claim makes failures attributable and safe to publish.

This is tool contract testing, not an attempt to model an agent's reasoning. The model never receives a production tool, and a passing run does not certify that prompts, permissions, or downstream systems are safe. For those layers, pair this lab with prompt-injection defenses for tool agents and a separate authorization review.

The boundary also establishes the oracle. Rejected input must produce a stable error code and no committed operation; accepted input must produce bounded normalized output. If the handler violates either rule, the test reports the input and its seed rather than celebrating a large random-case count.

Write that claim beside the fixture before adding scale. It tells reviewers which component is under test, which state counts as mutation, and which errors are intentionally normalized. When the real handler has several adapters, isolate the smallest deterministic core first, then test its effectful wrapper with separate integration evidence. Keep the boundary in the receipt so later readers do not widen the conclusion.

Schema loom from contract to invariant ledgerA five-stage conveyor turns a bounded schema into seeded valid calls, single-constraint hostile mutations, pure handler results, and an invariant ledger.SCHEMASEEDEDVALID1 MUTATIONPURE HANDLERERROR + EFFECTLEDGERseed + path preserved
The loom separates valid generation from one-axis mutations before a pure handler writes its evidence ledger.
  1. Compile only the declared JSON Schema subset.
  2. Generate schema-valid values from a recorded seed.
  3. Change exactly one applicable constraint.
  4. Run the synthetic, side-effect-free handler.
  5. Record validation, normalized error, and mutation invariants.

Compile a bounded JSON Schema subset

A useful compiler is explicit about what it understands. This lab supports object roots, nested objects, strings, numbers, integers, booleans, arrays, properties, required, additionalProperties, enum, scalar bounds, and array bounds. The JSON Schema Draft 2020-12 validation vocabulary defines far more; encountering an undeclared keyword here is an error, not a cue to ignore it.

That refusal matters because silent partial support creates false coverage. If a contract uses oneOf, a reference, a format assertion, or a conditional, the generator cannot honestly promise valid instances without implementing that semantic. A schema-based fuzzing tool should return unsupported-keyword with the exact path so the contract owner can reduce the schema or choose a fuller engine.

AI agent tool fuzzing also needs resource limits before recursion begins. The downloadable lab caps UTF-8 input at 64 KiB, nesting at eight levels, visited nodes at 1,024, generated collections at 32 items, strings at 4,096 bytes, and runs at 1,000. It rejects __proto__, prototype, and constructor wherever they appear as object keys.

Compile into a small internal node tree, then generate from that tree. Keeping parsing separate from generation makes unsupported behavior visible, produces a stable schema digest, and gives later mutations exact paths to target.

Generate valid cases before hostile mutations

Property-based testing works best when the passing domain is real. For each seed, the lab first generates a value that satisfies the supported schema: required keys exist, strings and arrays stay within bounds, numbers respect their interval, enum values come from the declared set, and closed objects contain no extras. The same signed 32-bit seed and schema yield the same ordered cases and receipt bytes.

Only after validation passes does the mutation layer change one constraint. A required-key mutation removes exactly one required member; an enum mutation substitutes a value outside the set; a bound mutation crosses one minimum or maximum; an array mutation changes its size; and an additional-property mutation adds one ordinary unknown key. Single-axis changes keep a rejection attributable.

AI agent tool fuzzing is therefore not random JSON sprayed at a parser. The coverage ledger records valid generation separately from each hostile family, along with tested, unsupported, and not-applicable states. That distinction prevents ten malformed objects from masquerading as evidence that the valid domain works.

The vocabulary matches the current fast-check explanation of generators, shrinkers, properties, and seeded runners, but the artifact is dependency-free and implements only its documented subset. Use tests that challenge generated intent to decide which domain mistakes deserve permanent examples after the mechanical layer finds them.

Mutation coverage constellationA matrix maps required, enum, bounds, array size, and additionalProperties keywords to missing, outside-set, boundary, cardinality, and unknown-key mutation families.KEYWORDMISSENUMRANGECOUNTEXTRArequiredenumminimum / maximumminItems / maxItemsadditionalProperties
The matrix refuses to call a mutation tested when it is unsupported or not applicable to the compiled schema.
Schema mutation coverage
KeywordMutationLedger state
requiredRemove one required keyTested when present
enumUse one outside-set valueTested when present
minimum / maximumCross one numeric boundaryTested when bounded
minItems / maxItemsCross one cardinality boundaryTested when bounded
additionalPropertiesAdd one ordinary unknown keyTested for closed objects

✓ means tested; — means not applicable. Unsupported keywords stop compilation and never appear as covered.

Attack seams the schema does not own

Schema-valid can still be operation-unsafe. The lab applies seam probes for unsafe object keys, oversized Unicode strings, cross-field contradictions, and duplicate semantic intent after the structural cases. A 400-by-400 poster may satisfy both numeric bounds while violating a handler rule that the total pixel budget cannot exceed 120,000.

Unknown properties deserve two tests. A closed schema should reject a harmless extra key through normal validation, while every schema should reject prototype-sensitive keys before compilation or cloning. Those outcomes have different error codes because an expected contract mismatch is not the same class of hazard as a hostile object shape.

The synthetic handler also checks that dryRun calls do not increment its committed-operation counter. For a supplied non-poster schema, the pixel seam uses an explicit 400-by-400 adapter output context recorded beside the schema-valid input; replay therefore does not pretend the dimensions came from undeclared arguments. That makes AI agent tool fuzzing evidence reproducible across custom contracts. The invariant sits outside JSON Schema by design: structure describes the call, while effect policy describes what the operation may change. AI agent tool fuzzing becomes useful when the ledger keeps those owners separate.

The OWASP agentic application guidance motivates defense in depth around tool misuse, but this narrow harness is not a security certification. It does not test goal hijacking, identity, network egress, or real credentials; AI agent fault injection covers failures beyond input structure.

Write invariants around the handler

Examples ask whether one input returns one expected value. Invariants ask what must remain true across every accepted and rejected case. The lab asserts five: rejected calls cause no mutation, normalized output stays under a byte ceiling, error codes remain from a closed vocabulary, dry runs are idempotent, and replaying the same valid input returns an identical digest.

The independent test oracle does not trust a PASS label from the program. It reads the JSON receipt, recalculates its SHA-256 digest from the custom schema, checks each returned valid case with a small separately written validator, and verifies every hostile case violates exactly the named rule. A deliberately broken adapter that commits before validation must be caught.

AI agent tool fuzzing should include business invariants only when they are deterministic and side-effect free in the harness. A cross-field pixel budget is appropriate; calling a billing service is not. Replace external effects with a strict fake whose history can be inspected after every run.

Keep error normalization equally narrow. Stable codes such as schema-required and policy-pixel-budget support regression comparisons, while raw exception messages can leak paths or vary across runtimes. The receipt records the normalized code and redacts values above the declared display limit.

Add a metamorphic check when the domain supports one. Reordering an object’s input keys should not change normalized poster output, while changing a required value should alter its digest. Those relations catch adapter mistakes without copying the generator’s expected values into the oracle, and they remain easy to explain in review.

Shrink failures without erasing their cause

A counterexample is useful when a person can understand why it fails. The lab starts from an observed, schema-relevant rejected call, then removes optional members, shortens bounded collections, and reduces unrelated scalar values only while preserving the normalized error and JSON path. The receipt computes its original and accepted serialized sizes at runtime instead of promising a prewritten byte staircase; the seed, schema digest, handler version, path, and mutation label remain fixed.

Shrinking must preserve the failure class, not merely any failure. Removing height could make the object smaller but would replace a business-invariant failure with a missing-required-property failure. Each proposed shrink is therefore rerun through structural validation and the handler oracle before it is accepted.

The minimal counterexample retains width: 400 and height: 400 plus the smallest required title and format. That is enough to cross 120,000 pixels without irrelevant palette entries. Minimized counterexamples become durable regression fixtures because they describe one rule with little incidental data.

AI agent tool fuzzing receipts store the original case, each accepted shrink size, the final raw input, normalized error, mutation label, seed, path, schema digest, and handler version. When agent tool schemas evolve, replay the old receipt before deciding whether a changed result is a bug or an intentional contract revision.

Failure shrinking staircaseA failing case descends through optional-field removal and value simplification while its computed error class, path, schema digest, seed, and handler version stay fixed.ORIGINAL · schema-relevant failureREMOVE · optional fieldsSIMPLIFY · unrelated valuesREPLAY · minimal casefixed: seed · path · schema digest · handler version · error class
The receipt computes every accepted serialized size and rejects any shrink that changes the original error class or path.
Original input
The receipt preserves the schema-relevant failing call before shrinking.
Optional removal
Only deletions that preserve the same normalized error and path are accepted.
Value simplification
Strings, arrays, and numbers are simplified only when the handler reports the same failure.
Minimal replay
The final raw input and its computed byte size are recorded instead of promised in advance.
Fixed evidence
The seed, path, schema digest, handler version, mutation label, and failure class stay fixed.

Contain execution and redact the receipt

The published program treats every schema as untrusted data. It reads one bounded file, parses JSON, validates tree depth and node count, checks keys with own-property operations, and refuses unsupported constructs before generation. There is no dynamic code evaluation, module import from the fixture, path expansion, network request, or artifact-side file write.

Run count and collection limits protect the test process from accidental explosions. A timeout still belongs around the process in CI because a harness cannot prove its own runtime scheduling, but deterministic bounded loops make that timeout meaningful. Store fixtures without customer values and never point the synthetic adapter at a production endpoint.

Receipts need the same discipline. Record hashes, paths, lengths, error codes, and compact redacted samples; do not copy tokens, filenames, personal content, or full prompts merely because a fuzz case found them. The downloadable artifact uses generated strings and synthetic poster data only.

This containment is part of AI agent tool fuzzing, not cleanup after it. A test that can mutate production while checking rejection has already violated its most important invariant. The safe loop is compile, generate, mutate, run a pure adapter, shrink, and promote only a redacted minimal case.

Runnable artifact — Documented JSON Schema subset and synthetic pure adapter; not authorization, complete JSON Schema generation, or security certification.

JavaScript31 lines
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";

const MAX_BYTES=65536,MAX_DEPTH=8,MAX_NODES=1024,MAX_COLLECTION=32,MAX_STRING=4096,MAX_VALUE_NODES=4096,MAX_VALUE_BYTES=262144,HANDLER_VERSION="render-poster-synthetic-v2";
const unsafe=new Set(["__proto__","prototype","constructor"]),allowed=new Set(["type","properties","required","additionalProperties","enum","minimum","maximum","minLength","maxLength","minItems","maxItems","items","description","title","default"]);
const builtIn={type:"object",additionalProperties:false,required:["title","format","width","height","palette"],properties:{title:{type:"string",minLength:1,maxLength:80},format:{type:"string",enum:["png","svg"]},width:{type:"integer",minimum:64,maximum:400},height:{type:"integer",minimum:64,maximum:400},palette:{type:"array",minItems:1,maxItems:5,items:{type:"string",minLength:4,maxLength:9}},dryRun:{type:"boolean"}}};
function normalized(value){if(Array.isArray(value))return value.map(normalized);if(value&&typeof value==="object")return Object.fromEntries(Object.keys(value).sort().map(key=>[key,normalized(value[key])]));return value;}
const canonical=value=>JSON.stringify(normalized(value)),digest=value=>createHash("sha256").update(canonical(value)).digest("hex"),bytes=value=>Buffer.byteLength(canonical(value));
function args(argv){const out={schema:null,seed:41,runs:24},seen=new Set();if(argv.length%2)throw new Error("invalid-arguments");for(let i=0;i<argv.length;i+=2){const flag=argv[i],value=argv[i+1];if(!["--schema","--seed","--runs"].includes(flag)||seen.has(flag))throw new Error("invalid-arguments");seen.add(flag);if(flag==="--schema")out.schema=value;if(flag==="--seed")out.seed=Number(value);if(flag==="--runs")out.runs=Number(value);}if(!Number.isInteger(out.seed)||out.seed< -2147483648||out.seed>2147483647)throw new Error("invalid-seed");if(!Number.isInteger(out.runs)||out.runs<1||out.runs>1000)throw new Error("invalid-runs");return out;}
function readSchema(file){if(!file)return structuredClone(builtIn);const input=readFileSync(file);if(input.length>MAX_BYTES)throw new Error("schema-too-large");try{return JSON.parse(input.toString("utf8"));}catch{throw new Error("malformed-schema");}}
function boundedInteger(value,min,max,label,path,optional=true){if(value==null&&optional)return;if(!Number.isSafeInteger(value)||value<min||value>max)throw new Error("invalid-"+label+":"+path);}
function compile(root){let nodes=0;function visit(node,path,depth){if(!node||typeof node!=="object"||Array.isArray(node))throw new Error("invalid-schema:"+path);if(depth>MAX_DEPTH||++nodes>MAX_NODES)throw new Error("schema-too-complex");for(const key of Object.keys(node)){if(unsafe.has(key))throw new Error("unsafe-key:"+path);if(!allowed.has(key))throw new Error("unsupported-keyword:"+path+"/"+key);}if(!["object","string","number","integer","boolean","array"].includes(node.type))throw new Error("unsupported-type:"+path);if(node.enum!=null){if(!Array.isArray(node.enum)||node.enum.length<1||node.enum.length>MAX_COLLECTION||bytes(node.enum)>MAX_VALUE_BYTES)throw new Error("invalid-enum:"+path);}
if(node.type==="string"){boundedInteger(node.minLength,0,MAX_STRING,"string-bound",path);boundedInteger(node.maxLength,0,MAX_STRING,"string-bound",path);}
if(node.type==="array"){boundedInteger(node.minItems,0,MAX_COLLECTION,"collection-bound",path);boundedInteger(node.maxItems,0,MAX_COLLECTION,"collection-bound",path);if(!node.items)throw new Error("missing-items:"+path);visit(node.items,path+"/*",depth+1);}
if(node.type==="object"){const props=node.properties||{};if(!props||typeof props!=="object"||Array.isArray(props)||Object.keys(props).length>MAX_COLLECTION)throw new Error("invalid-properties:"+path);if(node.additionalProperties!=null&&typeof node.additionalProperties!=="boolean")throw new Error("invalid-additional-properties:"+path);for(const key of Object.keys(props)){if(unsafe.has(key))throw new Error("unsafe-key:"+path);visit(props[key],path+"/"+key,depth+1);}if(node.required!=null&&(!Array.isArray(node.required)||node.required.length>MAX_COLLECTION||new Set(node.required).size!==node.required.length||node.required.some(key=>typeof key!=="string"||!Object.hasOwn(props,key))))throw new Error("invalid-required:"+path);}
for(const key of ["minimum","maximum"])if(node[key]!=null&&(!Number.isFinite(node[key])||(node.type==="integer"&&!Number.isSafeInteger(node[key]))))throw new Error("invalid-number-bound:"+path);for(const [lo,hi] of [["minimum","maximum"],["minLength","maxLength"],["minItems","maxItems"]])if(node[lo]!=null&&node[hi]!=null&&node[lo]>node[hi])throw new Error("invalid-bounds:"+path);return node;}
if(root?.type!=="object")throw new Error("non-object-root");visit(root,"$",0);function estimate(node){if(node.enum)return{nodes:1,bytes:Math.min(MAX_VALUE_BYTES+1,bytes(node.enum))};if(node.type==="string")return{nodes:1,bytes:4*Math.max(node.minLength??1,Math.min(node.maxLength??12,12))};if(["number","integer","boolean"].includes(node.type))return{nodes:1,bytes:24};if(node.type==="array"){const child=estimate(node.items),count=Math.max(node.minItems??0,Math.min(node.maxItems??3,3));return{nodes:1+count*child.nodes,bytes:2+count*(child.bytes+1)};}let total={nodes:1,bytes:2};for(const [key,childNode] of Object.entries(node.properties||{})){const child=estimate(childNode);total.nodes+=child.nodes;total.bytes+=Buffer.byteLength(key)+child.bytes+4;}return total;}const estimateResult=estimate(root);if(estimateResult.nodes>MAX_VALUE_NODES||estimateResult.bytes>MAX_VALUE_BYTES)throw new Error("schema-generated-value-too-large");return root;}
function rng(seed){let state=seed>>>0||1;return()=>((state=(state*1664525+1013904223)>>>0)/4294967296);}
function generate(node,random){if(node.enum)return structuredClone(node.enum[Math.floor(random()*node.enum.length)]);if(node.type==="string"){const count=Math.max(node.minLength??1,Math.min(node.maxLength??12,3+Math.floor(random()*8)));return Array.from({length:count},()=>String.fromCharCode(97+Math.floor(random()*26))).join("");}if(node.type==="integer")return Math.floor((node.minimum??0)+random()*((node.maximum??20)-(node.minimum??0)+1));if(node.type==="number")return Number(((node.minimum??0)+random()*((node.maximum??20)-(node.minimum??0))).toFixed(3));if(node.type==="boolean")return random()>=.5;if(node.type==="array"){const count=Math.max(node.minItems??0,Math.min(node.maxItems??3,1+Math.floor(random()*3)));return Array.from({length:count},()=>generate(node.items,random));}const out={};for(const key of node.required||[])out[key]=generate(node.properties[key],random);for(const [key,child] of Object.entries(node.properties||{}))if(!Object.hasOwn(out,key)&&random()>=.5)out[key]=generate(child,random);return out;}
function validate(node,value,path="$"){const fail=code=>({ok:false,code,path});if(node.enum&&!node.enum.some(item=>canonical(item)===canonical(value)))return fail("schema-enum");if(node.type==="object"){if(!value||typeof value!=="object"||Array.isArray(value))return fail("schema-type");for(const key of node.required||[])if(!Object.hasOwn(value,key))return{ok:false,code:"schema-required",path:path+"/"+key};if(node.additionalProperties===false)for(const key of Object.keys(value))if(!Object.hasOwn(node.properties||{},key))return{ok:false,code:"schema-additional",path:path+"/"+key};for(const [key,child] of Object.entries(node.properties||{}))if(Object.hasOwn(value,key)){const result=validate(child,value[key],path+"/"+key);if(!result.ok)return result;}return{ok:true};}if(node.type==="array"){if(!Array.isArray(value))return fail("schema-type");if(value.length<(node.minItems??0)||value.length>(node.maxItems??MAX_COLLECTION))return fail("schema-array-size");for(let i=0;i<value.length;i++){const result=validate(node.items,value[i],path+"/"+i);if(!result.ok)return result;}return{ok:true};}if(node.type==="string"){if(typeof value!=="string")return fail("schema-type");const count=Array.from(value).length;if(Buffer.byteLength(value)>MAX_STRING||count<(node.minLength??0)||count>(node.maxLength??MAX_STRING))return fail("schema-string-bound");return{ok:true};}if(node.type==="integer"&&!Number.isSafeInteger(value)||node.type==="number"&&(!Number.isFinite(value))||node.type==="boolean"&&typeof value!=="boolean")return fail("schema-type");if(typeof value==="number"&&(value<(node.minimum??-Infinity)||value>(node.maximum??Infinity)))return fail("schema-number-bound");return{ok:true};}
function setAt(root,segments,action){const copy=structuredClone(root);let cursor=copy;for(const segment of segments.slice(0,-1))cursor=cursor[segment];action(cursor,segments.at(-1));return copy;}
function mutations(schema,value){const out=[];function walk(node,current,segments,path){for(const key of node.required||[]){out.push({label:"required",path:path+"/"+key,input:setAt(value,segments.concat(key),(parent,last)=>delete parent[last])});break;}if(node.enum){out.push({label:"enum",path,input:segments.length?setAt(value,segments,(parent,last)=>{parent[last]="__outside_enum__";}):"__outside_enum__"});}if((node.type==="integer"||node.type==="number")&&(node.maximum!=null||node.minimum!=null)){const hostile=node.maximum!=null?node.maximum+1:node.minimum-1;out.push({label:"bounds",path,input:setAt(value,segments,(parent,last)=>{parent[last]=hostile;})});}if(node.type==="array"){if(node.maxItems!=null&&node.maxItems<MAX_COLLECTION){out.push({label:"array-size",path,input:setAt(value,segments,(parent,last)=>{parent[last]=Array.from({length:node.maxItems+1},()=>generate(node.items,()=>.25));})});}if(current.length)walk(node.items,current[0],segments.concat(0),path+"/0");}if(node.type==="object"){if(node.additionalProperties===false)out.push({label:"additionalProperties",path,input:setAt(value,segments,(parent,last)=>{const target=segments.length?parent[last]:parent;target.unexpected=true;})});for(const [key,child] of Object.entries(node.properties||{}))if(Object.hasOwn(current,key))walk(child,current[key],segments.concat(key),path+"/"+key);}}walk(schema,value,[],"$");return out;}
function handler(schema,input,state,adapterContext={}){const check=validate(schema,input);if(!check.ok)return{...check,committed:state.committed};if(Array.isArray(input.palette)&&new Set(input.palette.map(item=>String(item).trim().toLowerCase())).size!==input.palette.length)return{ok:false,code:"policy-duplicate-semantic-intent",path:"$/palette",committed:state.committed};const width=Number.isFinite(input.width)?input.width:adapterContext.outputWidth,height=Number.isFinite(input.height)?input.height:adapterContext.outputHeight;if(Number.isFinite(width)&&Number.isFinite(height)&&width*height>120000)return{ok:false,code:"policy-pixel-budget",path:"$/dimensions",committed:state.committed};if(!input.dryRun)state.committed++;const result=normalized(input);return{ok:true,normalized:result,digest:digest(result),committed:state.committed};}
function seamProbes(schema,base){const candidates=[];if(schema.properties?.title&&schema.properties?.width&&schema.properties?.height&&schema.properties?.palette){const unicode=structuredClone(base);unicode.title="💥".repeat(2049);candidates.push({label:"oversized-unicode",input:unicode,adapterContext:{}});const duplicate=structuredClone(base);duplicate.palette=["#000","#000"];candidates.push({label:"duplicate-semantic-intent",input:duplicate,adapterContext:{}});const contradiction=structuredClone(base);contradiction.width=400;contradiction.height=400;candidates.push({label:"cross-field-pixel-budget",input:contradiction,adapterContext:{}});}else{candidates.push({label:"cross-field-pixel-budget",input:structuredClone(base),adapterContext:{outputWidth:400,outputHeight:400}});}return candidates.map(item=>{const first=handler(schema,item.input,{committed:0},item.adapterContext),second=handler(schema,item.input,{committed:0},item.adapterContext);return{...item,rejected:!first.ok,error:first.code,path:first.path,noMutation:first.committed===0,replayStable:canonical(first)===canonical(second)};});}
function campaign(schema,seed,runs){const random=rng(seed),state={committed:0},valid=[],hostile=[];for(let i=0;i<runs;i++){const value=generate(schema,random);if(Number.isFinite(value.width)&&Number.isFinite(value.height)&&value.width*value.height>120000)value.height=Math.max(schema.properties.height.minimum??1,Math.floor(120000/value.width));const before=state.committed,result=handler(schema,value,state);if(!result.ok)throw new Error("generator-produced-invalid:"+result.code);valid.push({input:value,result});for(const mutation of mutations(schema,value)){const prior=state.committed,rejected=handler(schema,mutation.input,state);hostile.push({...mutation,rejected:!rejected.ok,error:rejected.code,path:rejected.path,noMutation:state.committed===prior});}if(value.dryRun&&state.committed!==before)throw new Error("dry-run-mutated");}const seams=seamProbes(schema,valid[0].input);return{valid,hostile,seams,committed:state.committed};}
function shrink(schema,candidate){const adapterContext=candidate.adapterContext||{},target=handler(schema,candidate.input,{committed:0},adapterContext),same=input=>{const observed=handler(schema,input,{committed:0},adapterContext);return !observed.ok&&observed.code===target.code&&observed.path===target.path;},sizes=[bytes(candidate.input)];let current=structuredClone(candidate.input);for(const key of Object.keys(current)){if(!(schema.required||[]).includes(key)){const proposal=structuredClone(current);delete proposal[key];if(same(proposal)){current=proposal;sizes.push(bytes(current));}}}for(const [key,node] of Object.entries(schema.properties||{})){if(!Object.hasOwn(current,key))continue;const proposal=structuredClone(current);if(node.type==="string")proposal[key]="x".repeat(Math.max(1,node.minLength??1));else if(node.type==="array")proposal[key]=proposal[key].slice(0,node.minItems??0);else if(node.type==="integer"||node.type==="number")proposal[key]=node.minimum??0;else continue;if(same(proposal)){current=proposal;sizes.push(bytes(current));}}return{originalInput:candidate.input,rawInput:current,adapterContext,sizes:[...new Set(sizes)],normalizedError:target.code,path:target.path,mutationLabel:candidate.label};}
const options=args(process.argv.slice(2)),schema=compile(readSchema(options.schema)),schemaDigest=digest(schema),primary=campaign(schema,options.seed,options.runs),replay=campaign(schema,options.seed,options.runs),labels=["required","enum","bounds","array-size","additionalProperties"],coverage=Object.fromEntries(labels.map(label=>[label,primary.hostile.some(item=>item.label===label)?"tested":"not-applicable"])),candidate=primary.seams.find(item=>item.label==="cross-field-pixel-budget"&&item.rejected)||primary.hostile.find(item=>item.rejected);
if(!candidate)throw new Error("no-applicable-counterexample");const reduced=shrink(schema,candidate),counterexample={seed:options.seed,schemaDigest,handlerVersion:HANDLER_VERSION,...reduced,invariant:"same normalized error and path; rejection causes no committed operation"};
const invariants={allValidAccepted:primary.valid.length===options.runs&&primary.valid.every(item=>item.result.ok),allHostileRejected:primary.hostile.every(item=>item.rejected),noMutationOnRejection:primary.hostile.every(item=>item.noMutation)&&primary.seams.every(item=>item.noMutation),closedErrorVocabulary:[...primary.hostile,...primary.seams].every(item=>/^schema-|^policy-/.test(item.error)),seamProbesExecuted:primary.seams.some(item=>item.label==="cross-field-pixel-budget"),seamReplayStable:primary.seams.every(item=>item.replayStable),replayStable:canonical(primary)===canonical(replay)};
const receipt={schema:"agent-tool-fuzz-receipt-v2",schemaDigest,handlerVersion:HANDLER_VERSION,seed:options.seed,runs:options.runs,limits:{schemaBytes:MAX_BYTES,depth:MAX_DEPTH,schemaNodes:MAX_NODES,collectionItems:MAX_COLLECTION,stringBytes:MAX_STRING,generatedValueNodes:MAX_VALUE_NODES,generatedValueBytes:MAX_VALUE_BYTES},coverage,validCaseDigests:primary.valid.map(item=>item.result.digest),mutationCounts:Object.fromEntries(labels.map(label=>[label,primary.hostile.filter(item=>item.label===label).length])),seamProbes:primary.seams.map(({label,adapterContext,rejected,error,path,noMutation,replayStable})=>({label,adapterContext,rejected,error,path,noMutation,replayStable})),invariants:{...invariants,boundedOutput:true},minimalCounterexample:counterexample,replayCommand:"node agent-tool-fuzz-lab.mjs"+(options.schema?" --schema <same-path>":"")+" --seed "+options.seed+" --runs "+options.runs,claimBoundary:"Documented JSON Schema subset and synthetic pure adapter; not authorization, complete JSON Schema generation, or security certification."};
receipt.invariants.boundedOutput=Buffer.byteLength(JSON.stringify(receipt))<1048576;console.log(JSON.stringify({...receipt,receiptHash:digest(receipt)},null,2));

Turn AI agent tool fuzzing into a release gate

A release gate should report evidence, not a vanity case count. Require valid coverage for every supported node kind, at least one applicable hostile mutation per constraint family, zero invariant failures, a stable replay hash, and a reviewed list of unsupported keywords. Keep the regression corpus small by promoting only minimized cases that reveal a distinct bug.

Run the built-in poster contract first, then pass --schema <path>, a signed 32-bit --seed, and --runs 1..1000 for a redacted real contract. Unknown, duplicated, or conflicting arguments fail before execution. A changed seed should change generated cases; an unchanged invocation should be byte-for-byte stable.

The decision remains human. A new unsupported keyword may block the gate, while a newly rejected hostile case may demonstrate a desired fix. Link the receipt to the handler version and review it alongside the schema authority described in JSON Schema vs Zod for agent tools.

AI agent tool fuzzing earns confidence only inside its claim boundary: the documented subset, seeded generator, single-axis mutations, pure adapter, and independent oracle. It does not grant authorization or prove an agent safe. That modest, replayable evidence is precisely what makes it useful before exposing a handler to a model.