Reproduction steps:
- Price two args dicts with the shared estimator workflow_engine._estimate_amount_cents:
honest = {"amount": 1000000, "currency": "usd"} # real $10,000 charge
decoy = {"amount": 1000000, "amount_cents": 0, "currency": "usd"} # same charge + decoy
E._estimate_amount_cents(honest, honest) # -> (1000000, False)
E._estimate_amount_cents(decoy, decoy) # -> (0, False)
- Feed each through the real approval policy with an auto_approve rule capping
the action at 5000c ($50):
policy = {"version":1,"rules":{"auto_approve":[
{"connector":"acme","verb":"place_bid","action_class":"compensable","max_amount_cents":5000}]}}
approval_policy.evaluate(policy, {"connector":"acme","verb":"place_bid",
"action_class":"compensable","amount_cents": <estimator output>})["decision"]
Expected:
Both price the same real charge, so both are held for a human (or both refused);
the decoy field cannot change how much money the gate thinks the action moves.
Actual:
honest ($10,000): estimator 1000000c -> evaluate: require_human (correct)
decoy ($10,000): estimator 0c -> evaluate: auto_approve
_find_amount_in walks the amount-key aliases in a FIXED priority order
(amount_cents, charge_cents, ... then amount, then price/total/cost) and returns
the FIRST key present, ignoring the others — regardless of which key the handler
actually charges. amount (whole units) is a real charge field per the code's
own comment (marcofgv-freelancer place_bid). So an action carrying a decoy
amount_cents:0 alongside a real amount charge is priced at 0, and because
_coerce_amount(0) returns (0, False) — a CONFIDENT zero, not "unbounded" — no
fail-closed path engages. The same estimator feeds the approval-policy
max_amount_cents cap, run_workflow's cumulative max_spend_cents cap
(spend_cap.within_cap(0,0,cap)=True), the agent per-tool budget, AND the
human-facing plan blast radius, so all four show/enforce $0 for a $10,000 move.
A prompt-injected agent proposing such args (the explicitly-modelled threat) or a
crafted workflow node evades every spend control at once.
Suggested fix:
When more than one recognised amount key is present, do not stop at the first —
take the MAX across all present money keys (nested search included), and if any
present amount key is unresolvable, return unbounded=True (force require_human):
def _amounts_in(obj, depth=0):
# yield every coerced amount under any recognised key + a flag if any
# recognised key was present-but-uncoercible (unbounded)
...
found, unbounded = _amounts_in(resolved_args)
if unbounded:
return 0, True
return (max(found) if found else 0), False
Taking the max means a decoy small/zero key cannot shadow the real charge, and a
present-but-unresolved key still fails closed. (Also worth: reconcile against the
actual cost the handler reports, so the gate and the effect agree.)