# Audit report

> Python spend-guard
>
> Re-audit the Python spend-guard folder only, at commit 716819c. It is a follow-up to audit job 355a3703, so check whether each of those findings is fixed. Cover numeric validation of quotes, caps and tokens, refusal of locally decidable inputs before the estimate call, the refused_local exit 3, and spend always being numeric. Treat every other folder as out of scope. Work offline and read-only. Give each finding a file and line, its impact, and steps to reproduce it.

| | |
|---|---|
| Repository | https://github.com/uxp-labs/flop-devnet-lab.git |
| Commit | `716819c41f8c655d8305db2a382a6b4e356d9e80` |
| Job | `d7e11822-7a41-4120-86b2-e1206c40b430` |
| Judged | 2026-11-10 15:08 UTC |
| Findings | 5 low · 6 info |

Four agents audited the code as it is at `716819c`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Low: I11 fix incomplete: --max-tokens above MAX_TOKEN_COUNT and a cap below 1e-6 can never send, yet the prompt is still POSTed to the estimate (and sub-1e-6 caps falsely refuse quotes far under them)

`spend-guard/guard.py:91`

```
    if a.max_tokens < 0 or cap is None or cap <= 0:
```

Merged from audit_math, audit_permissions, audit_economics and audit_flow. The pre-estimate gate only refuses max_tokens<0 and cap<=0/non-finite. Two more input classes can never reach would_send: (1) max_tokens > MAX_TOKEN_COUNT (10_000_000): line 113 needs int(tout) >= max_tokens while nonnegative_count (line 40) needs tout <= 10_000_000. (2) 0 < cap < 1e-6: any accepted padded is ceil6(...) > 0, i.e. >= 1e-6, and line 137 needs padded <= cap. Both still send the full prompt to /models/estimate, which is what I11 set out to stop. In case (2) this is also a false refusal: a quote whose exact padded cost is 1.2e-8 is refused under a 5e-7 cap because the decision uses the rounded-up padded value. Fix: extend line 91 with `or a.max_tokens > MAX_TOKEN_COUNT or cap < 1e-6` (and document the 1e-6 cap floor in README), add both cases to test_PY_I11.

**Reproduction**

Offline via spend-guard/test_guard_numeric.run_guard(body,'1',args) (urlopen stubbed and counted). (a) body {estimated_cost_flop:0.066, estimated_input_tokens:3, estimated_output_tokens:8}, args --user Hi --max-tokens 10000001 --cap 1 -> exit 3, urlopen 1, refused_local quote_padded null. Same with estimated_output_tokens 10000001 and --cap 1000. Control: --max-tokens -5 -> exit 3, urlopen 0. (b) body {1e-8,3,8}, --max-tokens 8 --cap 5e-7 -> exit 3, urlopen 1, quote_padded 1e-06, refused_local; the same body with --cap 1e-6 -> would_send exit 0. Expected for (a) and (b): urlopen 0 (no prompt sent), refused_local, exit 3.

### 2. Low: --max-tokens uses bare int(): underscores, whitespace, '+' and non-ASCII digits are accepted though --cap (L7) rejects them as typos

`spend-guard/guard.py:84`

```
    ap.add_argument("--max-tokens", type=int, default=8)
```

Merged from all four specialists. cap_arg (lines 50-55) rejects '1_0', ' 1 ' and non-ASCII digits with exit 2 before any estimate. --max-tokens, which goes into the estimate request and the coverage check on line 113, is parsed by int(), which silently turns '1_6' into 16 and Arabic-Indic U+0668 into 8. A typo therefore changes the request sent and the ledger value instead of being rejected. Fix: an ASCII-only converter, e.g. re.fullmatch(r'[+-]?[0-9]+', text, re.ASCII) before int(), raising ArgumentTypeError.

**Reproduction**

Offline via spend-guard/test_guard_numeric.run_guard(body,'1',args) (urlopen stubbed and counted). body {0.066,3,8}, --user Hi --cap 1 and --max-tokens '1_6' -> exit 3, urlopen 1, ledger max_tokens 16; --max-tokens ' 8 ' and --max-tokens '\u0668' -> exit 0, would_send, max_tokens 8, urlopen 1. Comparison: --max-tokens 8 --cap 1_0 -> exit 2 'invalid cap', urlopen 0. Expected: exit 2, urlopen 0, no ledger row for each max-tokens spelling.

### 3. Low: Padded quotes above ~4096 FLOP are sometimes refused whatever the cap: the 1e-12 absolute tolerance is below one float ulp after ceil6 snaps down

`spend-guard/guard.py:121`

```
                    if json_number(candidate) is not None and candidate > 0 and candidate >= unrounded - 1e-12:
```

ceil6 snaps to the nearest 1e-6 step when within 1e-6, so nearest/1e6 can be one ulp below unrounded. Above ~4096 one ulp exceeds 1e-12, `unrounded - 1e-12` rounds back to unrounded, the check fails, unrounded is cleared and the decision is refused_local even with a cap far above the quote. Fail-closed (no overspend) but a false, non-deterministic refusal inside the spec's 20,000 FLOP key range. Fix: relative tolerance (e.g. max(1e-12, 4*math.ulp(unrounded))) or do the round-up exactly with Fraction.

**Reproduction**

Offline via spend-guard/test_guard_numeric.run_guard(body,'1',args) (urlopen stubbed and counted). body {estimated_cost_flop:2590.545485, estimated_input_tokens:3, estimated_output_tokens:8}, --user Hi --max-tokens 8 --cap 20000 -> exit 3, refused_local, quote_padded null (unrounded 7300.628185000001, ceil6 7300.628185). Expected would_send exit 0. Control: raw 2590.5 -> would_send, quote_padded 7300.5. A seeded sweep of 20,000 quotes in 1000..20000 with 11 tokens hit 2 failures, e.g. raw 6369.682011.

### 4. Low: receipts.py crashes (traceback, exit 1, no rows) when a pass-through receipt field holds NaN/Infinity

`spend-guard/receipts.py:13`

```
        with urllib.request.urlopen(req, timeout=15) as r: return r.status, json.load(r), None
```

json.load accepts NaN/Infinity literals. classify_receipt sanitises only the spend fields; request_id, state and terminal_event pass through, and dump() uses allow_nan=False, raising an uncaught ValueError. All remaining rows are lost and the docstring's 'one JSON row and exit 0' contract breaks. Fix: json.load(r, parse_constant=<raise ValueError>) so the body goes to the existing unparsable-body handler on line 18, or sanitise pass-through fields.

**Reproduction**

Run receipts.py with urllib.request.urlopen stubbed to return status 200 and body [{"request_id":"r1","state":NaN,"settled_spend":0.1}] (also [{"request_id":Infinity}]), key file {"api_key":"sk_synthetic"}: ValueError 'Out of range float values are not JSON compliant', exit 1, nothing on stdout. Control: [{"settled_spend":NaN}] -> one row with invalid ["settled_spend"], exit 0. Expected: a row, exit 0.

### 5. Low: receipts.py forwards the bearer key across redirects to any origin, including plain http

`spend-guard/receipts.py:11`

```
    req = urllib.request.Request(BASE + path, headers={"Authorization": f"Bearer {key}", "User-Agent": "uxp-labs-flop-spend-guard/0 (+https://github.com/uxp-labs)"})
```

Authorization is a normal header, and urllib's default HTTPRedirectHandler copies it into the redirected request without an origin or scheme check. A 30x from the gateway path (open redirect, misconfigured edge) sends the long-lived sign-up key to a third host, possibly in cleartext. Fix: an opener whose redirect handler refuses redirects (or strips Authorization on origin change), reporting a 3xx as an error row.

**Reproduction**

Local only: server A on 127.0.0.1 answers GET with 302 Location http://localhost:<portB>/collect; server B records the Authorization header and returns []. Set guard.BASE to http://127.0.0.1:<portA>/v1, key file {"api_key":"sk_synthetic_not_a_key"}, run receipts.py --key-file via runpy. Actual: server B received 'Bearer sk_synthetic_not_a_key', exit 0. Expected: key sent only to the gateway origin, redirect refused.

### 6. Info: Estimate failures are recorded as a bare refused_local row; the error text is dropped, so an outage is indistinguishable from a policy refusal

`spend-guard/guard.py:97`

```
            e = {"error": str(ex)}
```

e['error'] is never read or written to stderr/the ledger. A network failure gives the same row and exit 3 as --cap nan or an unusable quote. Keep fail-closed exit 3 but record a reason field or print str(ex) to stderr.

**Reproduction**

Offline via spend-guard/test_guard_numeric.run_guard(body,'1',args) (urlopen stubbed and counted). With urlopen raising URLError, or returning body {"error":"x"}, args --user Hi --max-tokens 8 --cap 1 -> exit 3, empty stderr, row quote_raw null, quote_padded null, refused_local, nothing identifying the cause. Expected: a reason/stderr line distinguishing estimate failure.

### 7. Info: -h/--help exits 0, the would_send status, so the README's `guard.py ... && next-step` gate proceeds with no decision

`spend-guard/guard.py:80`

```
    ap = argparse.ArgumentParser()
```

README line 15 says 0 means would_send and to gate on the status; argparse help exits 0 with no estimate and no ledger row. Fix: add_help=False with a help action that exits non-zero (not 3), or document it.

**Reproduction**

Offline via spend-guard/test_guard_numeric.run_guard(body,'1',args) (urlopen stubbed and counted). args --user Hi --cap 1 -h -> exit 0, urlopen 0, no ledger.jsonl. `python3 guard.py --user Hi --cap 1 -h && echo SENT` prints SENT.

### 8. Info: Padded quote computed in float: for integer quotes above 2**53 the exact padded cost can exceed the cap while would_send shows quote_padded == cap

`spend-guard/guard.py:117`

```
                unrounded = float(raw) + TEMPLATE_PAD * float(per_tok)
```

L2 made raw<=cap exact with Fraction, but the padded comparison uses rounded floats, contradicting the 'rounding cannot sneak a quote through' comment. Only matters for absurd quote sizes. Fix: also check Fraction(raw)*(total+TEMPLATE_PAD)/total <= Fraction(cap).

**Reproduction**

Offline via spend-guard/test_guard_numeric.run_guard(body,'1',args) (urlopen stubbed and counted). body {estimated_cost_flop:1152921504660450687, estimated_input_tokens:1, estimated_output_tokens:8}, --max-tokens 8 --cap 3.714969292794785e+18 -> would_send, exit 0, quote_padded == cap; exact padded 1152921504660450687*29/9 exceeds Fraction(cap) by 779. Expected refused_local exit 3.

### 9. Info: L5 residual: ceil6 rounds a positive padded quote below ~1e-12 to 0, so it is refused

`spend-guard/guard.py:46`

```
    if abs(scaled - nearest) <= 1e-6:
```

For x <= ~1e-12 the nearest step is 0, ceil6 returns 0.0 and line 121's candidate > 0 refuses. Fail-closed. Fix: only use the snapped value when nearest > 0.

**Reproduction**

Offline via spend-guard/test_guard_numeric.run_guard(body,'1',args) (urlopen stubbed and counted). body {1e-13,3,8}, --max-tokens 8 --cap 1 -> refused_local, quote_padded null, exit 3. Expected per L5: would_send, quote_padded 1e-06.

### 10. Info: L6 residual: one input token is accepted as covering a prompt of any length

`spend-guard/guard.py:113`

```
                and int(tout) >= max(a.max_tokens, 0) and (int(tin) >= 1 or not prompt)):
```

Only tin >= 1 is required, so a quote undercounting a 10,000-character prompt by orders of magnitude passes, and the pad is priced from that undercount. Fix: require tin >= a conservative bound such as ceil(len(prompt.encode())/32).

**Reproduction**

Offline via spend-guard/test_guard_numeric.run_guard(body,'1',args) (urlopen stubbed and counted). body {0.004,1,1}, args --system ('x '*5000) --user Hi --max-tokens 1 --cap 0.1 -> would_send, quote_padded 0.044, exit 0. With tin 0 the same input is refused (PY_L6).

### 11. Info: receipts.py prints nothing for an empty receipt list, and a missing --last-known file ends in a traceback

`spend-guard/receipts.py:55`

```
    if not rows: return
```

Docstring promises one JSON row and exit 0 for every outcome. An empty list prints nothing; on non-200, line 48 json.load(open(a.last_known)) is unguarded. Fix: print a {"http":200,"receipts":0} row and wrap the last-known read.

**Reproduction**

Stub urlopen: 200 body [] or {"receipts":[]} -> exit 0, empty stdout. HTTPError 401 body {"error":"api_key_expired"} with --last-known /nonexistent/x.json -> FileNotFoundError traceback, exit 1, no row. Expected: one row each, exit 0.

---

Judge's submission `df6c32604785930d38e50016ca41d13258e244368c5f7d9a6f052a092f6ba0cd`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
