# Audit report

> Re-audit the Python spend-guard folder only, at commit 3ae1cdf. It is a follow-up to audit job d7e11822, so check whether each of those findings is fixed, especially the partial I11 (max-tokens above 10M and caps below 1e-6 must be refused before the estimate call). 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 | `3ae1cdfa55707dd8e02678acdba57faec5aa50bf` |
| Job | `1ad85c03-adab-40b3-ad52-eb82387ffdf6` |
| Judged | 2026-11-10 16:41 UTC |
| Findings | 8 low · 4 info |

Four agents audited the code as it is at `3ae1cdf`, 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: receipts.py: a NaN/Infinity in request_id, state or terminal_event of any 200 receipt aborts with a traceback and drops that receipt and every later one

`spend-guard/guard.py:69`

```
    out = {"request_id": r.get("request_id"), "state": r.get("state"),
```

Merged from audit_permissions (e458378a), audit_economics (f615377d) and audit_flow (3adb7cdb): same mechanism, same fix. receipts.get parses the 200 body with json.load, which accepts the non-standard literals NaN, Infinity and -Infinity. The 355a3703 L10 fix only sanitises settled_spend and reserved_spend through spend_amount. classify_receipt (guard.py:69 and :74) copies request_id, state and terminal_event straight through, and receipts.dump (receipts.py:35) serialises with allow_nan=False, so the first such value raises ValueError at receipts.py:57. Nothing catches it: the process exits 1 with a traceback and prints no row for that receipt nor for any receipt after it in the list, against the module docstring's promise that every outcome, including a malformed body, is one JSON row and exit 0. Impact: a single malformed receipt from the gateway blocks the whole reconciliation listing, including a later 'released' or 'settled' row that SPEC.md step 4 says a human needs before a resend. Fail-closed (no spend), so low. Fix: in classify_receipt keep request_id/state only when they are str (else None and append to 'invalid'), and pass terminal_event only if json.dumps(value, allow_nan=False) succeeds (else None and 'invalid'); or parse with json.load(r, parse_constant=...) that rejects the constants, as the tests' strict_load does, and emit an error row for that receipt.

**Reproduction**

Through the test_audit_v7.run_receipts harness (urlopen stubbed to return 200). Body [{"request_id":"r1","state":NaN,"settled_spend":0.4},{"request_id":"r2","state":"settled","settled_spend":0.1}] with `receipts.py --key-file k.json`. Expected: two strict-JSON rows and exit 0, r1 flagged invalid, r2 settled 0.1. Actual (reproduced at 3ae1cdf): ValueError('Out of range float values are not JSON compliant: nan'), exit 1, empty stdout; r2's settled spend is never shown. Same result for [{"request_id":"r1","state":"settled","terminal_event":{"block":NaN}}] and for [{"request_id":Infinity,"state":"settled"}] (ValueError ... inf). Control: [{"request_id":"r1","state":"settled","settled_spend":NaN}] prints one row with settled null and invalid ["settled_spend"], exit 0.

### 2. Low: receipts.py --last-known is opened and parsed unguarded on the error path, so a missing, non-JSON or NaN file turns the error row into a traceback

`spend-guard/receipts.py:48`

```
        if a.last_known: out["last_known"] = json.load(open(a.last_known))
```

Merged from audit_permissions (1f2a6d16) and audit_math (2bfe04ec). The --last-known file is read only inside the code != 200 branch, exactly when the gateway read has already failed (401 expired key, 503, timeout). json.load(open(a.last_known)) is unguarded, and its result goes through dump(allow_nan=False). A missing file raises FileNotFoundError, a non-JSON file raises JSONDecodeError, and a saved receipt containing NaN raises ValueError in dump. In every case the structured row with http/error/flag is lost and the process exits 1 with a traceback, against the docstring's 'every outcome ... one JSON row and exit 0' and the 355a3703 L8 fix. Fix: wrap the read in try/except (OSError, ValueError), parse with a parse_constant that rejects NaN/Infinity, and on failure set out['last_known_error'] = type(e).__name__ while still printing the row and exiting 0.

**Reproduction**

Harness test_audit_v7.run_receipts. (a) urlopen raises urllib.error.URLError('timed out'); run `receipts.py --key-file k.json --last-known /nonexistent/x.json`. Expected: one row {"http": null, "error": "URLError", "state": null, ...}, exit 0. Actual: FileNotFoundError, exit 1, empty stdout. (b) urlopen raises HTTPError 401 with body {"error":"api_key_expired"}; lk.json contains {"settled_spend": NaN}; `--last-known lk.json`. Actual: ValueError('Out of range float values are not JSON compliant: nan'), exit 1, empty stdout, the key_expired flag is lost. (c) same 401 with lk2.json containing `not json`: JSONDecodeError, exit 1. Control: lk3.json {"settled_spend": 0.4} prints the 401 row with flag key_expired and last_known, exit 0.

### 3. Low: guard.py --max-tokens uses bare int(), so the underscore, whitespace, plus-sign and non-ASCII digit spellings that --cap refuses as typos are silently reinterpreted and sent to the estimate

`spend-guard/guard.py:85`

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

Merged from audit_permissions (c0f7db97), audit_flow (7c9e9da8) and audit_math (d23c295b). cap_arg (guard.py:51-56, the 355a3703 L7 fix) states the folder's rule: underscores, non-ASCII digits and whitespace are typos, not amounts, and exit 2 before any estimate request. --max-tokens is parsed with the builtin int, which accepts PEP 515 underscores, any Unicode Nd digit (Arabic-Indic, fullwidth), surrounding whitespace and a leading '+'. The same spelling refused for the amount is accepted for the token count, the prompt leaves the machine in an estimate request, and the ledger records a max_tokens the operator did not type. The cap still bounds the spend, so the impact is an unintended token budget and inconsistent validation. Fix: give --max-tokens a type function with the same ASCII rule, e.g. re.fullmatch(r'[+-]?[0-9]+', text, re.ASCII) then int(), raising argparse.ArgumentTypeError otherwise (keep '-1' reaching the refused_local row if that is still intended).

**Reproduction**

Harness test_guard_numeric.run_guard with estimate body {"estimated_cost_flop": 0.066, "estimated_input_tokens": 3, "estimated_output_tokens": 20}. `guard.py --user Hi --max-tokens 1_6 --cap 1`: expected exit 2 and 0 estimate requests, as `--cap 1_0` gives. Actual (3ae1cdf): exit 0, would_send, one estimate request, ledger max_tokens 16. Same for --max-tokens ' 16 ' and '١٦' (max_tokens 16), '+8' and '８' (fullwidth, max_tokens 8), and '1_0000000' (max_tokens 10000000, one estimate request). Controls: --cap '1_0', ' 1' and '١' each exit 2 with 0 requests.

### 4. Low: guard.py ceil6 can round a valid padded quote DOWN by one ulp, and the 1e-12 round-down check then discards it as malformed, refusing an in-cap quote above a few thousand FLOP

`spend-guard/guard.py:124`

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

From audit_math (e777a6ac), reproduced. ceil6 scales by 1e6 and accepts the nearest integer when |scaled - nearest| <= 1e-6 (an absolute tolerance). Once the unrounded padded quote is in the thousands of FLOP, scaled is ~1e9-1e10, where one float ulp of scaled is 1e-6 to 4e-6 and one ulp of the quote itself is ~1e-12. A quote that sits one ulp above a 1e-6 grid point scales to exactly that integer, is snapped down to nearest/1e6, and then fails `candidate >= unrounded - 1e-12` by 1-4e-12. Line 124-127 treats this as a malformed quote: padded and unrounded become None, and the row is refused_local with quote_padded null, indistinguishable from a bad gateway body, although the quote is far under the cap. Fail-closed, so no overspend; the harm is a spurious refusal of a legitimate request (about 0.1% of 6-decimal quotes at 1e4 FLOP and up) and a ledger row that misreports a valid quote as unusable. Fix: compute the 6-decimal ceiling exactly (math.ceil(Fraction(x) * 10**6) / 10**6 as a Fraction, then float), or make both tolerances relative to math.ulp(scaled) and math.ulp(x).

**Reproduction**

Harness test_guard_numeric.run_guard; body {"estimated_cost_flop": 2050.745279, "estimated_input_tokens": 3, "estimated_output_tokens": 8}; `guard.py --user Hi --max-tokens 8 --cap 30000`. unrounded = 2050.745279 + 20*(2050.745279/11) = 5779.373059000001; ceil6 returns 5779.373059 (scaled is exactly 5779373059.0), which is < unrounded - 1e-12. Expected: would_send, exit 0, quote_padded 5779.37306. Actual (3ae1cdf): refused_local, exit 3, quote_padded null. Controls: 2050.745278 -> would_send 5779.373057; 2050.745280 -> would_send 5779.373062. Second instance: 9848.480873 -> refused_local/null (unrounded 27754.809733000002, ceil6 27754.809733), while 9848.480874 -> would_send 27754.809736.

### 5. Low: receipts.py --request-id is interpolated into the URL path unencoded, an empty id silently lists everything, and the returned request_id is never matched against the one asked for

`spend-guard/receipts.py:41`

```
    path = f"/keys/me/receipts/{a.request_id}" if a.request_id else "/keys/me/receipts"
```

From audit_permissions (9e602939), reproduced. The path is an f-string with no quoting or validation, so '/', '..', '?' and '#' in --request-id change which resource the Bearer key is sent to. The reply is classified and printed with no check that its request_id equals the one requested, and an empty --request-id '' is falsy, so it falls back to the full listing. A pasted or corrupted request id therefore makes the tool report another receipt's state and spend as the requested one, and SPEC.md step 4 says that report decides whether a resend is allowed. Fix: refuse an empty id or one outside [A-Za-z0-9_-] (or urllib.parse.quote(id, safe='')), test `is not None` instead of truthiness, and when an id was given emit an error row if the returned request_id differs.

**Reproduction**

Harness test_audit_v7.run_receipts with urlopen stubbed to record req.full_url and return 200 {"request_id":"other","state":"settled"}. (a) --request-id '../../models' requests https://api.flop.network/v1/keys/me/receipts/../../models; (b) 'r1?all=1' requests .../receipts/r1?all=1; (c) 'r1#x' requests .../receipts/r1#x; (d) '' requests the list endpoint .../receipts. In every case (3ae1cdf) the tool prints {"request_id": "other", "state": "settled", ...} and exits 0. Expected: a refusal or encoding of the id, and a mismatch error row instead of reporting 'other' as the requested receipt.

### 6. Low: receipts.py sends a request with an empty bearer token ('Authorization: Bearer ') when the key file is empty or whitespace-only

`spend-guard/receipts.py:23`

```
    raw = open(path).read().strip()
```

From audit_math (604de4c4), reproduced. read_key strips the file and, when the text is not JSON, returns it verbatim as the key. An empty or whitespace-only file yields key '' and main proceeds to get(), which sends GET /keys/me/receipts with the header 'Authorization: Bearer ' (nothing after Bearer). The JSON-without-key-field branch (line 32) exits with 'nothing sent'; the empty plain-text case, the common truncated-or-not-yet-written key file, should be at least as strict, since the tool's rule is to send nothing when it has no key. Fix: after strip(), if raw is empty, sys.exit('receipts.py: key file is empty; nothing sent') before any request.

**Reproduction**

Harness test_audit_v7.run_receipts with key_text '' (and separately ' \n'); urlopen stubbed to record req.get_header('Authorization') and return 200 []. Run `receipts.py --key-file key.txt`. Expected: exit non-zero with a 'nothing sent' message and no urlopen call, as for a JSON key file without api_key/key/token. Actual (3ae1cdf): exit 0, one request sent, recorded header exactly 'Bearer '.

### 7. Low: receipts.py follows redirects with the Authorization header attached, so a 3xx from the gateway forwards the bearer key to whatever host the Location names, 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)"})
```

From audit_flow (70fbebc4), reproduced with two local servers. get() sets Authorization as an ordinary header and calls urllib.request.urlopen, whose default HTTPRedirectHandler follows 301/302/303/307/308 on GET and copies every header except Content-Length/Content-Type into the new Request with no same-origin or https check. A redirect in the response to GET /keys/me/receipts[/{id}] (a misconfigured gateway or CDN edge, or an https-to-http redirect) therefore sends the key, in cleartext when the target is http, to a third party, and the redirected body is printed as if it came from the gateway. The README says keys are never printed or logged; SPEC.md says the broad sign-up key can mint child keys worth up to 20,000 FLOP. Rated low rather than the specialist's medium: whoever controls the original response already received the key in that request, so the added exposure is a legitimate-but-misconfigured gateway handing the key to a third-party host, or an http downgrade exposing it on the path. Fix: install an opener whose HTTPRedirectHandler.redirect_request returns None (or raises) for authenticated requests and report a 3xx as an error row; at minimum drop Authorization when the scheme or host changes.

**Reproduction**

Offline, outside the repo: server A on 127.0.0.1 answers every GET with `302 Location: http://localhost:<B>/collect`; server B logs the Authorization and Host headers and returns 200 [{"request_id":"r1","state":"settled","settled_spend":0.1,"reserved_spend":0.1}]. Import receipts, set receipts.BASE='http://127.0.0.1:<A>/v1', key file {"api_key":"sk-SECRET-123"}, sys.argv=['receipts.py','--key-file',path], call receipts.main(). Expected: the redirect is refused or the key stripped, so B sees no credential. Actual (3ae1cdf): B received 'Authorization: Bearer sk-SECRET-123' with Host localhost:<B> and path /collect, and receipts.py printed B's receipt row (settled 0.1) as the gateway's.

### 8. Low: receipts.py prints nothing and exits 0 for an empty receipt list, so 'no such receipt' is indistinguishable from the tool producing nothing

`spend-guard/receipts.py:55`

```
    if not rows: return
```

Merged from audit_permissions (9d84188f) and audit_math (2b48f73f). When the 200 body is [] or {"receipts": []} (or {"data": []}), main returns before printing anything, against the docstring's 'every outcome ... is one JSON row and exit 0'. With --request-id, a wrapper that reads one row gets no row at all and cannot tell 'receipt not found' from an empty account or a silent failure. Fix: emit one row such as {"http": 200, "error": "no receipts", "state": null, "request_id": <id or null>} instead of returning silently.

**Reproduction**

Harness test_audit_v7.run_receipts; urlopen stubbed to return 200 with body [] ; run `receipts.py --key-file k.json --request-id r1`. Expected: one strict-JSON row with state null and an error. Actual (3ae1cdf): exit 0, empty stdout. Same for body {"receipts": []} without --request-id.

### 9. Info: guard.py ceil6 snaps a positive padded quote at or below 1e-12 to 0, so it is refused while a larger quote under the same cap is accepted (non-monotonic)

`spend-guard/guard.py:47`

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

From audit_economics (566d5832), reproduced. ceil6 accepts the nearest 1e-6 boundary when |scaled - nearest| <= 1e-6. For an unrounded padded quote in (0, 1e-12], scaled <= 1e-6, nearest is 0, and ceil6 returns 0.0. Line 124 then requires candidate > 0, so the quote is refused_local with quote_padded null even with a cap of 1, while 3e-12 under the same inputs is would_send with quote_padded 1e-06. Fail-closed, so no cost, but the decision is not monotonic in the quote and contradicts the MIN_CAP comment (guard.py:8) that the padded quote is rounded up to at least 1e-6. Fix: in ceil6, when nearest == 0 and x > 0 return math.ceil(scaled)/1e6, or apply max(1e-6, ...) to any positive x.

**Reproduction**

Harness test_guard_numeric.run_guard; body {"estimated_cost_flop": 1e-13, "estimated_input_tokens": 3, "estimated_output_tokens": 8}; `guard.py --user Hi --max-tokens 8 --cap 1`. Expected: would_send, quote_padded 1e-06, exit 0. Actual (3ae1cdf): refused_local, quote_padded null, exit 3. Controls: estimated_cost_flop 1e-12, 3e-12 and 1e-7 each give would_send, quote_padded 1e-06, exit 0.

### 10. Info: guard.py compares the 6-decimal ceiling of the quote against a cap that may carry more than six decimals, so an operator cap like 0.0500001 behaves as 0.05

`spend-guard/guard.py:140`

```
    decision = "would_send" if a.max_tokens >= 0 and cap is not None and cap > 0 and padded is not None and unrounded is not None and unrounded <= cap and padded <= cap and raw_within_cap else "refused_local"
```

From audit_math (63d2626c), reproduced. cap_arg accepts any ASCII decimal, including one finer than the 1e-6 grid, but the decision requires padded <= cap where padded is the 6-decimal ceiling of the quote. Any quote strictly between 0.05 and such a cap rounds up to 0.050001 > cap and is refused although both unrounded and raw are below the cap. The README rule 'refuses locally if the result is over your cap' is applied against the six-decimal floor of the cap, which the operator is never told. Conservative, so no overspend. Fix: reject caps with more than six decimals in cap_arg (same spirit as MIN_CAP) or compare padded against ceil6(cap).

**Reproduction**

Harness test_guard_numeric.run_guard; body {"estimated_cost_flop": 0.017741953225806274, "estimated_input_tokens": 3, "estimated_output_tokens": 8} (unrounded 0.0500000499999995, padded 0.050001). `guard.py --user Hi --max-tokens 8 --cap 0.0500001`: actual (3ae1cdf) refused_local, exit 3, row shows quote_padded 0.050001 above cap 0.0500001 while quote_raw <= cap. Control: `--cap 0.050001` -> would_send, exit 0. Expected: would_send for the 0.0500001 cap too, or exit 2 rejecting a cap finer than 1e-6.

### 11. Info: guard.py spend_amount accepts -0.0, so a receipt row can print a minus-signed spend

`spend-guard/guard.py:61`

```
    return n if n is not None and n >= 0 else None
```

From audit_math (c63513fa), reproduced. The docstring says a spend is a finite number >= 0; -0.0 satisfies n >= 0 and is returned unchanged, so classify_receipt emits "settled": -0.0 on the operator's reconciliation output, which downstream string matching or sign checks can read as negative. Fix: normalise with `n + 0.0` (or `0 if n == 0 else n`) before returning.

**Reproduction**

From the spend-guard folder: python3 -c 'import json,guard; print(json.dumps(guard.classify_receipt({"request_id":"r1","state":"settled","settled_spend":-0.0,"reserved_spend":0.1})))'. Expected: "settled": 0.0. Actual (3ae1cdf): "settled": -0.0. Same through receipts.py (harness run_receipts) with a 200 body [{"request_id":"r1","state":"settled","settled_spend":-0.0,"reserved_spend":0.1}]: the printed row contains "settled": -0.0, exit 0.

### 12. Info: guard.py applies the fixed 20-token Qwen-with-system template pad to every --model and to the default empty --system, so the pad does not follow the template it is meant to cover

`spend-guard/guard.py:120`

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

From audit_economics (934af872). What is reproduced: the pad is a constant (TEMPLATE_PAD = 20, line 6, measured once for Qwen with system 'Be brief.' and user 'Hi') and line 120 applies it regardless of --model (line 82 accepts any model) or whether --system is given (default ''). SPEC.md step 1 says the pad exists so the quote matches what really gets billed. For another model's template, or for Qwen's own default system message inserted when --system is empty, the real wrap differs from the measured one while the pad stays 20 tokens, so a quote just under the cap could be would_send while the billed cost is above the cap, with the child key's max_spend (SPEC.md, not yet verified) as the only backstop. Not verified offline: the actual billed token count for any template, so this is reported as a trust assumption, not a demonstrated overspend. Fix: key the pad by model and by system/no-system (measured per template) and refuse models without a measured pad, or send the templated prompt to the estimate.

**Reproduction**

Harness test_guard_numeric.run_guard with body {"estimated_cost_flop": 0.009, "estimated_input_tokens": 1, "estimated_output_tokens": 8}. `guard.py --user Hi --max-tokens 8 --cap 1` (no --system), the same with `--model other/model-x`, and `--system 'Be brief.' --user Hi ...` all give quote_raw 0.009 and quote_padded 0.029 (pad 20*0.009/9 = 0.02) at 3ae1cdf: identical pad for every model and for system present or absent. Expected: a pad that depends on the model and on whether a system message is present, or a refusal for an unmeasured model.

---

Judge's submission `d78265a0e4c969d660e463722850e31ae1af0c053b610c48aa5e73d33d92c0ad`, 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.
