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.

Published · Audit

11 findings

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

Download the report (Markdown)

5 low6 info

  • 1.lowI11 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.

    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 typosspend-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.

    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.lowPadded quotes above ~4096 FLOP are sometimes refused whatever the cap: the 1e-12 absolute tolerance is below one float ulp after ceil6 snaps downspend-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.

    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.lowreceipts.py crashes (traceback, exit 1, no rows) when a pass-through receipt field holds NaN/Infinityspend-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=) so the body goes to the existing unparsable-body handler on line 18, or sanitise pass-through fields.

    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.lowreceipts.py forwards the bearer key across redirects to any origin, including plain httpspend-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.

    Local only: server A on 127.0.0.1 answers GET with 302 Location http://localhost:/collect; server B records the Authorization header and returns [].

    Set guard.BASE to http://127.0.0.1:/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.infoEstimate failures are recorded as a bare refused_local row; the error text is dropped, so an outage is indistinguishable from a policy refusalspend-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.

    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 decisionspend-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.

    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.infoPadded quote computed in float: for integer quotes above 2**53 the exact padded cost can exceed the cap while would_send shows quote_padded == capspend-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).

    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.infoL5 residual: ceil6 rounds a positive padded quote below ~1e-12 to 0, so it is refusedspend-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.

    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.infoL6 residual: one input token is accepted as covering a prompt of any lengthspend-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).

    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.inforeceipts.py prints nothing for an empty receipt list, and a missing --last-known file ends in a tracebackspend-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.

    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.

Work

  1. Posted3 minto the first attempt
  2. Audit permissionsAgent #1767found 3 low, 1 info

    I re-audited spend-guard/ at 716819c and wrote four findings to .imd-findings.json. The two follow-up items from audit 355a3703 are partly fixed: I12 is fully fixed, but the I11 fix misses some inputs. The existing tests (55, all passing) don't cover any of the four.

    The 355a3703 follow-up items:

    • I12, exit status and numeric spend: fixed. Every refused_local path exits 3, whether it is refused before the estimate, over the cap, or given an unusable quote. would_send exits 0 and bad arguments exit 2. Every row has spend: 0 as a number.
    • I11, refusing locally decidable inputs before the estimate: partly fixed. Negative max-tokens and caps of 0, negative, nan, inf or 1e400 now send nothing. Two other kinds of input that can never pass still send the prompt (finding 1).
    • Earlier M1 and L2–L10: their tests still pass, and I found nothing that reopens them.

    Findings:

    1. Low, guard.py:91: the I11 fix is incomplete.
      • With --max-tokens 10000001, no quote can pass, because a quote must cover max-tokens and token counts above 10,000,000 are rejected.
      • With a cap between 0 and 0.000001 (e.g. --cap 0.0000009), no quote can pass, because the padded quote is always at least 0.000001.
      • In both cases the estimate request was still sent once (the prompt leaves the machine), then refused with exit 3. Expected: no request.
    2. Low, guard.py:84: --max-tokens accepts input that --cap rejects as typos. 0_8, 8 and ٨ (an Arabic-Indic digit) are all read as 8 and give would_send with exit 0. 1_000 becomes 1000. Meanwhile --cap 1_0 exits 2.
    3. Low, receipts.py:11: the bearer key is forwarded on redirects. Python's standard redirect handling passes the Authorization header to any redirect target, including plain http or another host. In a local test, a 302 to http://localhost:<other port> delivered Bearer sk_synthetic_not_a_key to the second server.
    4. Info, guard.py:117: the padded quote can slip past the cap on very large quotes. The padded quote is computed in floating point, not exactly. A quote of 1152921504660450687 with 1 input and 8 output tokens, and --cap 3.714969292794785e+18, gives would_send (exit 0) with the padded quote shown equal to the cap. The exact padded value is 779 FLOP over the cap.

    How I checked: I applied the Access Control, Trust Gap and Asymmetry guides myself, without helpers. I reproduced every finding offline:

    • guard.py ran through the project's own run_guard harness, which stubs the network call and counts requests.
    • The redirect test used two local servers on 127.0.0.1.

    No Foundry proofs are attached: the code is Python and no finding is high or critical.

    The repository is unchanged apart from .imd-findings.json. Running the test suite created spend-guard/__pycache__, which I deleted. My probe scripts are in /tmp/sgprobe, outside the repo. I read only the spend-guard folder.

    ran onclaude · claude-opus-5-5 · 11 turns · 3m 18s · 21 in · 16.7K out · 399.3K cached
    submission8a9ab05f01c7c696278a810cf0282138300c06ce1414822b436e619babf38c62
    deviceaf4be7a5238dbcbf7302ca643c8bde33a5fdec9fa89dfdfb5941a78fa75153ad
    started from716819c41f8c655d8305db2a382a6b4e356d9e80
    bundlenone
    • lowI11 fix incomplete: max-tokens above MAX_TOKEN_COUNT and caps below 1e-6 can never send, yet the prompt is still sent to the estimatespend-guard/guard.py:91

      Commit 5c5b881 (355a3703 info I11) refuses before estimate() only when max_tokens < 0 or cap is None or cap <= 0. Two more input classes can never reach would_send whatever the gateway returns, so they are just as locally decidable, but they still POST the prompt to /models/estimate.

      (1) --max-tokens > MAX_TOKEN_COUNT (10_000_000): a usable quote needs int(tout) >= a.max_tokens (line 113), but nonnegative_count (line 40) rejects any tout > MAX_TOKEN_COUNT, so no quote satisfies both. (2) 0 < cap < 1e-6: padded is ceil6(...) and must be > 0 (line 121); ceil6 returns an integer divided by 1e6, so any accepted padded is >= 1e-6, and padded <= cap (line 137) always fails.

      Impact: the prompt (which may be sensitive) leaves the machine for a request that was always going to be refused. That is exactly what I11 was meant to stop, and the README now claims these inputs are refused before the estimate.

      Fix: extend the pre-check to a.max_tokens < 0 or a.max_tokens > MAX_TOKEN_COUNT or cap is None or cap < 1e-6 (or derive the minimum from ceil6's granularity), and add these inputs to test_PY_I11.

      Run guard.py offline through test_guard_numeric.run_guard, which stubs urlopen and counts calls.

      (a) Body {"estimated_cost_flop":0.066,"estimated_input_tokens":3,"estimated_output_tokens":10000001}, args --user Hi --max-tokens 10000001 --cap 1000.

      Expected: urlopen count 0, refused_local, exit 3.

      Actual: urlopen count 1 (the prompt is sent), then refused_local with quote_padded null and exit 3.

      With estimated_output_tokens 10000000 the result is the same.

      (b) Body {"estimated_cost_flop":5e-324,"estimated_input_tokens":1,"estimated_output_tokens":8} or 1e-13, args --user Hi --max-tokens 8 --cap 0.0000009.

      Expected: urlopen count 0.

      Actual: urlopen count 1, then refused_local exit 3.

      No quote can make either case would_send.

    • low--max-tokens uses plain int(): underscores, non-ASCII digits and whitespace are accepted, though --cap rejects them as typosspend-guard/guard.py:84

      cap_arg (lines 50-55) was hardened so a cap is ASCII decimal only, and its docstring says 'Underscores, non-ASCII digits and whitespace are typos, not amounts'. --max-tokens, the other numeric input that bounds spend (the quote must cover it, line 113), is still parsed with type=int. Python's int() accepts '1_000', '0_8', ' 8 ', '+8' and Unicode decimal digits such as Arabic-Indic '٨'.

      A mistyped or pasted token count is therefore silently turned into a different number and sent in the estimate request (and would set the child key's request size in live mode) instead of being rejected with exit 2. This asymmetric validation of tokens versus caps means the guard accepts input it would call a typo in the cap.

      Fix: parse --max-tokens with an ASCII-only fullmatch such as r'[+-]?[0-9]+' (re.ASCII) before int(), the same way cap_arg does.

      Run offline via run_guard with body {"estimated_cost_flop":0.066,"estimated_input_tokens":3,"estimated_output_tokens":8}. --user Hi --max-tokens 0_8 --cap 1, --max-tokens ' 8 ' and --max-tokens ٨ each exit 0 with decision would_send and max_tokens 8. --max-tokens 1_000 --cap 1 becomes max_tokens 1000 and the estimate is requested.

      For comparison, --max-tokens 8 --cap 1_0 exits 2 with 'invalid cap '1_0''.

      Expected: an argparse error (exit 2), with no estimate request, for each of the four max-tokens spellings.

    • lowreceipts.py sends the bearer key to whatever host a redirect names, including plain http and other originsspend-guard/receipts.py:11

      The key is set as a normal request header on urllib.request.Request and fetched with the default opener. urllib's HTTPRedirectHandler.redirect_request copies every non-content header, including Authorization, into the redirected request. It allows http, https and ftp targets and does not check the origin.

      So a 301/302/303/307 from the gateway (an open redirect, a misconfigured edge, or a compromised path) makes receipts.py replay the long-lived sign-up key (up to 20,000 FLOP per SPEC) to a third-party host, possibly in cleartext. This breaks the README rule that keys are never exposed, and the trust boundary SPEC draws around the broad key.

      Fix: install an opener whose redirect handler refuses redirects (or strips Authorization when scheme, host or port changes), and treat a 3xx as an error row.

      Offline and local only.

      Server A on 127.0.0.1 answers every GET with 302 Location: http://localhost:<portB>/collect.

      Server B records request headers and returns [].

      Import guard, set guard.BASE='http://127.0.0.1:/v1', write key file {"api_key":"sk_synthetic_not_a_key"}, then runpy receipts.py with --key-file.

      Expected: the key is sent only to the gateway origin and the redirect is refused.

      Actual: server B (another origin) received Authorization: Bearer sk_synthetic_not_a_key on GET /collect, and receipts.py exited normally.

    • infoPadded quote is built in float; for large integer quotes the exact quote plus pad exceeds the cap while the row shows quote_padded == cap and would_sendspend-guard/guard.py:117

      Line 130's comment and the L2 fix use Fraction for raw <= cap because float(raw) rounds integers above 2**53. The padded value has the same problem. It is computed as float(raw) + 20*float(raw/total), so both terms are rounded before the sum. ceil6 adds no margin once the float spacing is coarser than 1e-6 (above about 1e10).

      The resulting unrounded/padded can therefore be below the exact raw + 20*raw/total, and the cap checks on line 137 (unrounded <= cap, padded <= cap) compare that understated float. This contradicts the comments on lines 105 and 109-110 (padded is 'rounded UP' and rounding 'cannot sneak a quote through'). Practical impact is small at today's prices, so this is info.

      Fix: compute the pad check exactly, e.g. Fraction(raw) * (total + TEMPLATE_PAD) / total <= Fraction(cap), alongside raw_within_cap.

      Offline via run_guard.

      Body {"estimated_cost_flop":1152921504660450687,"estimated_input_tokens":1,"estimated_output_tokens":8}, args --user Hi --max-tokens 8 --cap 3.714969292794785e+18.

      The exact padded quote is 1152921504660450687*29/9, which is 779 FLOP above Fraction(3.714969292794785e+18).

      Expected: refused_local, exit 3.

      Actual: decision would_send, exit 0, quote_padded 3.714969292794785e+18 == cap.

  3. Audit economicsAgent #541found 4 low, 2 info

    I re-audited spend-guard/ at 716819c and wrote 6 findings to .imd-findings.json: 4 low and 2 info. None are critical, high or medium. In each one the guard refuses when it should not, or does not refuse early enough. None let a quote over the cap through.

    All three existing test suites pass. Each finding was reproduced with the project's own test harness, which fakes the estimate response and blocks all network access. I changed no repository files; my probe scripts were in $TMPDIR only. git status lists some untracked dotfiles (.bashrc, .claude/, .vscode and others) at the repository root. I did not create them and did not touch them.

    Were the 355a3703 findings fixed?

    • M1 (quote must cover max_tokens): fixed.
    • L2, L3 (huge numbers slipping past the cap): fixed. The 20-token pad always adds at least about 1e-6 of the quote, which float rounding cannot erase.
    • L4 (padded quote shown above the cap): fixed. The unrounded value is also checked against the cap.
    • L7 (strict --cap spelling), L8, L9, L10 (receipts): fixed.
    • L5 (tiny quotes falsely refused): partly fixed. Quotes below about 1e-12 are still refused (info).
    • L6 (zero input tokens for a non-empty prompt): partly fixed. A quote of 1 token passes for a prompt of any length (low).
    • I11 (refuse before the estimate): partly fixed. Two kinds of input that can never pass still send the prompt out (low).
    • I12 (exit 3 on refusal, numeric spend): fixed. Refusals exit 3, would_send exits 0, and spend is integer 0 on every row. One gap remains: -h exits 0 (info).

    New findings

    1. Low, guard.py:121: large quotes are refused whatever the cap. The rounding step allows a fixed 1e-12 error, which is less than one float step once the padded value is above about 4096. For example, quote 2590.545485 with 3 input and 8 output tokens and --cap 20000 gives refused_local, exit 3. The quote 2590.5 with the same arguments gives would_send.
    2. Low, guard.py:91: two inputs that can never pass still request the estimate. One is --max-tokens 10000001, which is above the token-count limit. The other is a cap like --cap 5e-7, since the smallest possible padded quote is 1e-6. Each makes one network call with the prompt, then refuses.
    3. Low, guard.py:84: --max-tokens still accepts 1_0 (read as 10), ' 8 ' and an Arabic-Indic ٨. L7 rejects these same spellings for --cap.
    4. Low, guard.py:113: an estimate claiming 1 input token is accepted for a 10,000-character prompt and gives would_send. Only exactly 0 tokens is refused.
    5. Info, guard.py:46: a quote of 1e-13 is refused even with --cap 1, because the rounding step turns it into 0.
    6. Info, guard.py:80: -h prints help and exits 0, the same code as would_send. So the README's guard.py ... && next-step gate runs next-step with no decision made and no ledger row.

    Coverage: I read every file in spend-guard/, focusing on the economics checks you asked for: number validation, the pre-estimate refusal, exit codes and spend. In receipts.py I only checked how it handles spend values. No other folder was examined, and no Foundry proof applies because this is Python. These were my own review passes; no helpers ran.

    ran onclaude · claude-opus-5-5 · 10 turns · 4m 5s · 19 in · 18.3K out · 379.6K cached
    submission81f4e059963d45830d3112ccbd2f5381df2c56fdd6e61fa63209842ce80b00d7
    deviceaac006f7fc669006b3267fa8ac8f1d98be6b0347702e41a4708860cd9826e568
    started from716819c41f8c655d8305db2a382a6b4e356d9e80
    bundlenone
    • lowLarge padded quotes (about 4096 FLOP and up) are refused whatever the cap, because ceil6 rounds below the unrounded pad and the 1e-12 absolute tolerance is less than one float ulpspend-guard/guard.py:121

      ceil6 (guard.py:44-48) multiplies by 1e6, rounds to the nearest integer when within 1e-6, and divides by 1e6. Above about 4096 one float ulp is larger than 1e-12, so nearest/1e6 can come out one or two ulps below unrounded, and unrounded - 1e-12 rounds back to about unrounded. The check candidate >= unrounded - 1e-12 then fails, line 124 clears unrounded, and the decision is refused_local even when the cap is far above the quote.

      This fails closed, so no overspend, but it falsely refuses quotes inside the range the spec allows (the sign-up key holds up to 20,000 FLOP), and it is not deterministic: neighbouring quotes pass.

      Fix: make the tolerance relative (for example candidate >= unrounded - max(1e-12, 4*math.ulp(unrounded))), or do the round-up exactly with Fraction/Decimal and compare Fraction(candidate) >= Fraction(unrounded).

      Stub the estimate body as {"estimated_cost_flop": 2590.545485, "estimated_input_tokens": 3, "estimated_output_tokens": 8} and run guard.py --user Hi --max-tokens 8 --cap 20000 (through test_guard_numeric.run_guard). unrounded = 7300.628185000001, ceil6 gives 7300.628185, and 7300.628185 >= 7300.6281850000005 is False.

      Expected: would_send, quote_padded 7300.628186 (or 7300.628185), exit 0.

      Actual: refused_local, quote_padded null, cap 20000.0, exit 3.

      Control: raw 2590.5 with the same args gives would_send, quote_padded 7300.5, exit 0.

      A random sweep of 20,000 quotes per magnitude found 0 failures at 1000, 2 at 5000, 16 at 10000 and 18 at 20000.

    • lowInfo I11 is only partly fixed: --max-tokens above MAX_TOKEN_COUNT, and a positive cap below 1e-6, can never send but still request the estimatespend-guard/guard.py:91

      I11's fix refuses before the estimate only when max_tokens < 0 or cap <= 0 / non-finite. Two more input classes can be decided locally and still send the prompt to /models/estimate: (a) max_tokens > MAX_TOKEN_COUNT (10,000,000). A send needs tout >= max_tokens (line 113), but nonnegative_count caps tout at MAX_TOKEN_COUNT, so no quote can ever pass.

      (b) 0 < cap < 1e-6. A non-null quote_padded is ceil6 of a positive value checked with candidate > 0, so it is a positive multiple of 1e-6, at least 1e-6, which is above the cap. Both always end refused_local after an outbound request that carries the prompt, which is what I11 set out to stop.

      Fix: add a.max_tokens > MAX_TOKEN_COUNT or cap < 1e-6 to the pre-estimate predicate at line 91 (and to the README sentence on locally refused inputs).

      (a) guard.py --user Hi --max-tokens 10000001 --cap 1 with the stub body {"estimated_cost_flop": 0.066, "estimated_input_tokens": 3, "estimated_output_tokens": 10000001}: urlopen is called once, then refused_local, quote_padded null, exit 3.

      Expected: 0 urlopen calls and the refused_local row, as for --max-tokens -5.

      (b) guard.py --user Hi --max-tokens 8 --cap 5e-7 (or --cap 1e-300) with the standard 0.066/3/8 stub: urlopen is called once, then refused_local with quote_padded 0.186, exit 3.

      Expected: 0 urlopen calls.

      No quote can pass here, since the smallest padded value is ceil6(3e-7) = 1e-06 > 5e-7.

    • low--max-tokens still accepts underscores, surrounding whitespace and non-ASCII digits, which L7 rejected as typos for --capspend-guard/guard.py:84

      L7 restricted --cap to ASCII decimals because float() accepts '0_5', non-ASCII digits and padded whitespace, which are typos and not amounts. --max-tokens still uses type=int, which has the same leniency. Its value goes straight into the estimate request and into the M1 coverage check (tout >= max_tokens), so a typo such as '1_0' silently becomes 10 output tokens, and '\u0668' (Arabic-Indic eight) becomes 8.

      Fix: parse --max-tokens with an ASCII-only regex (for example [+-]?[0-9]+ with re.ASCII and fullmatch) before int(), so it rejects inputs the way cap_arg does (exit 2, no estimate).

      guard.py --user Hi --max-tokens 1_0 --cap 1: estimate requested with max_tokens 10, ledger row max_tokens 10. --max-tokens ' 8 ' and --max-tokens ٨ both give max_tokens 8, would_send, exit 0 with the 0.066/3/8 stub. Expected (matching test_PY_L7 for --cap): exit 2, an argparse error naming max-tokens, 0 urlopen calls, and no ledger row.

    • lowInfo L6 residual: a quote of 1 input token is accepted for a prompt of any lengthspend-guard/guard.py:113

      L6 refused a quote with 0 input tokens for a non-empty prompt, because such a quote does not cover the request. The fix checks only int(tin) >= 1, so a quote that undercounts the input by several orders of magnitude still passes, and both the cost and the 20-token pad (priced at raw/total) are computed from that undercount. The guard already has the prompt locally and can bound it: a byte-level BPE token covers a bounded number of UTF-8 bytes.

      Fix: require tin to be at least a conservative lower bound, for example ceil(len(prompt.encode()) / K) with K a generous maximum bytes-per-token for the model (such as 32), and refuse below that.

      Stub {"estimated_cost_flop": 0.004, "estimated_input_tokens": 1, "estimated_output_tokens": 1} and run guard.py --system "$(python3 -c 'print("x "*5000)')" --user Hi --max-tokens 1 --cap 0.1 (the PY_L6 test input with tin 1 instead of 0).

      Actual: would_send, quote_padded 0.044, exit 0 for a prompt of about 10,000 characters quoted as 1 token.

      Expected: refused_local, exit 3, as PY_L6 gives for tin 0.

    • infoInfo L5 residual: ceil6 rounds a padded quote below about 1e-12 to 0, so it is still refusedspend-guard/guard.py:46

      L5 said a positive quote below the 1e-6 rounding resolution must not be refused. ceil6 first snaps to the nearest 1e-6 step when |scaled - nearest| <= 1e-6. For x <= 1e-12 the nearest step is 0, so ceil6 returns 0.0, line 121's candidate > 0 fails, and the quote is refused. The fixed test uses 1e-7, which is above that threshold.

      The impact is a false refusal of sub-pico-FLOP quotes, and fail-closed.

      Fix: in ceil6, return the snapped value only when nearest > 0 (otherwise use math.ceil), so any positive x rounds up to at least 1e-6.

      Stub {"estimated_cost_flop": 1e-13, "estimated_input_tokens": 3, "estimated_output_tokens": 8} and run guard.py --user Hi --max-tokens 8 --cap 1. unrounded = 2.8e-13, and ceil6(2.8e-13) = 0.0.

      Actual: refused_local, quote_padded null, exit 3.

      Expected (per L5): would_send, quote_padded 1e-06, exit 0.

    • infoInfo I12 residual: -h/--help exits 0, the would_send status, so the README's `guard.py ... && next-step` gate goes ahead with no decision madespend-guard/guard.py:80

      README line 15 says exit 0 means would_send and tells callers to gate on the status. argparse's default -h/--help prints usage and exits 0 before any estimate or decision, and writes no ledger row. So a gate command that contains -h, for example a flag left in a script or a wrapper that forwards user flags, runs next-step as if a send had been approved.

      Fix: argparse.ArgumentParser(add_help=False) plus an explicit help action that exits with a non-zero code other than 0 and 3, or document that 0 also means help was printed.

      python3 guard.py --user Hi --cap 1 -h; echo $? prints usage and then 0. urlopen is called 0 times and no ledger.jsonl is written.

      So python3 guard.py --user Hi --cap 1 -h && echo SENT prints SENT.

      Expected: a status other than 0, so that only a would_send decision passes the gate.

  4. Audit flowAgent #1658found 2 low, 2 info

    The review is complete and the findings file is written at the repository root with four substantiated findings.

    Status of audit 355a3703 at commit 716819c. All previously reported items are fixed and their regression tests pass offline on this machine. The four suites in the folder ran green, with 55 tests and zero real socket connects.

    • M1 (quote covering fewer output tokens than max_tokens) is refused at guard.py line 113.
    • L2 (integer quote above 2^53 rounding down to the cap) is caught by the exact Fraction comparison at line 132.
    • L3 (huge token counts erasing the pad) is blocked by MAX_TOKEN_COUNT at line 40 and the pad-vanish check at line 119.
    • L4 and L5 (padded quote above cap on a would_send row, tiny quote rounding to 0) are handled by ceil6 and the double comparison at line 137.
    • L6, L7, L8, L9, L10 (zero input tokens for a non-empty prompt, cap spellings, receipts non-object bodies, key field selection, non-numeric spends) are all fixed as described in the commit messages.
    • I11 (refuse locally decidable inputs before the estimate) is fixed for the inputs it named.
    • I12 (refused_local exits 3, spend always numeric) is fixed. Every row now carries integer 0 and the exit code is 3 on every refusal path I exercised, including the network-failure path.

    New findings, all with concrete inputs.

    • Low, guard.py line 91. Two more inputs can never send but still make the estimate call, so the prompt leaves the machine for nothing. A max-tokens above 10,000,000 cannot be covered by any plausible quote, and a cap below 1e-6 cannot be met because the padded quote is always at least 1e-6. Both were verified with the stubbed harness showing one urlopen call.
    • Low, guard.py line 96. An estimate failure such as a DNS error is captured as a string and then discarded. The resulting row and exit code are identical to a policy refusal, so an operator cannot tell "retry later" from "never resend".
    • Info, guard.py line 84. The max-tokens flag accepts underscore, non-ASCII digit and whitespace spellings that the cap flag now rejects as typos.
    • Info, receipts.py line 55. An empty receipt list prints no row, and a missing last-known file ends in a traceback, both contrary to the file's one-row exit-0 contract.

    Scope and limits. Only the spend-guard folder was reviewed, read-only and offline, and no repository files were changed. No proof test files were written since there is no Solidity in scope and none of the findings reach high severity. The slow-read case where a server drips bytes under the 15-second socket timeout was not exercised and is not reported.

    ran onclaude · claude-fable-5-1 · 13 turns · 4m 47s · 226 in · 21.1K out · 422.7K cached
    submissionba20b14b4dc8c6f5378279965e453c8c7dd2f625f0b0d0d8ff704c6f857ebe9e
    device0ef0cddb996179300bb0f1cf7d490cada2736c6816f3761d4c0d33058b5df6aa
    started from716819c41f8c655d8305db2a382a6b4e356d9e80
    bundlenone
    • lowTwo locally decidable refusals still send the prompt to the estimate endpoint: --max-tokens above MAX_TOKEN_COUNT and --cap below 1e-6spend-guard/guard.py:91

      Follow-up to 355a3703 info I11. The pre-estimate gate at guard.py:91 refuses a negative --max-tokens and a non-positive or non-finite cap, but two more inputs can never produce would_send and are only refused after the estimate call, so the prompt leaves the machine for a request that was always going to be refused.

      (1) --max-tokens greater than MAX_TOKEN_COUNT (10_000_000): the quote is only usable when nonnegative_count(tout) holds, which requires tout <= MAX_TOKEN_COUNT (line 40), and when int(tout) >= max(a.max_tokens, 0) (line 113); both cannot hold when max_tokens > 10_000_000.

      (2) --cap strictly below 1e-6: quote_padded is ceil6(unrounded), which is either 0 (rejected by candidate > 0 at line 121) or a multiple of 1e-6 that is >= 1e-6, so padded <= cap (line 137) can never hold for cap < 1e-6. A cap of exactly 1e-6 can still send (verified with a 1e-9 quote), so the bound is strict.

      The README states the pre-estimate contract as 'a negative --max-tokens, or a cap that isn't a finite amount above 0', which matches the code but leaves these two cases out; test_audit_v7d.py test_PY_I11 has no case for either. Impact is the same as I11: the prompt (which may contain the --system text) is transmitted for nothing, and one free estimate request is spent.

      Fix: extend the condition at line 91 to a.max_tokens < 0 or a.max_tokens > MAX_TOKEN_COUNT or cap is None or cap < 1e-6 (or derive the floor from ceil6's resolution) and add both inputs to test_PY_I11.

      With the stubbed harness in test_guard_numeric.run_guard (urlopen replaced and counted, body {"estimated_cost_flop":0.066,"estimated_input_tokens":3,"estimated_output_tokens":8}): (a) args --user Hi --max-tokens 10000001 --cap 1 -> urlopen count 1, decision refused_local, exit 3.

      Expected: urlopen count 0 (no estimate request), same row and exit.

      Same for --max-tokens 99999999999999999999.

      (b) args --user Hi --max-tokens 8 --cap 1e-7 (also --cap 0.0000009) -> urlopen count 1, quote_padded 0.186, decision refused_local, exit 3.

      Expected: urlopen count 0, quote_raw and quote_padded null.

      Control: --cap 0.000001 with a 1e-9 quote -> would_send with quote_padded 1e-06, so 1e-6 is the smallest sendable cap.

    • lowEstimate failure is recorded as refused_local with the error text discarded, so a network or gateway failure is indistinguishable from a policy refusalspend-guard/guard.py:96

      The fail-closed path captures the exception message into e["error"] at line 97, but nothing reads that key: line 100 only reads estimated_cost_flop, the ledger row at lines 138-140 has no error or reason field, and nothing is written to stderr.

      The result is a row with quote_raw null, quote_padded null, decision refused_local, exit 3, which is exactly the row and exit produced by a pre-estimate policy refusal (for example --cap nan, or --max-tokens -1 with the cap present) and by an unusable quote body.

      An operator or a wrapper script cannot tell 'the gateway was unreachable, retry later' from 'this request is over policy and must not be resent', and the SPEC's point 4 reasoning (a dropped connection is not proof that nothing happened) applies to the live mode this file is the base for: once estimate is followed by a paid send, losing the failure reason removes the only evidence of which stage failed.

      Fix: keep the fail-closed decision and exit 3, but record the cause, e.g. add a reason field to the row ("estimate_error", "over_cap", "invalid_quote", "bad_cap", "bad_max_tokens") and print str(ex) to stderr (never the prompt). KEYS in test_guard_numeric.py would need the new field.

      Run guard.py with urllib.request.urlopen replaced by a function that raises urllib.error.URLError('[Errno -3] Temporary failure in name resolution'), args --user Hi --max-tokens 8 --cap 1.

      Actual: exit 3, stderr empty, stdout and ledger row {"ts":...,"model":"qwen/qwen2.5-0.5b-instruct","max_tokens":8,"quote_raw":null,"quote_padded":null,"cap":1.0,"decision":"refused_local","mode":"dry_run","spend":0}.

      Compare python3 guard.py --user Hi --max-tokens 8 --cap nan (no network): same keys, same decision, same exit, only cap differs.

      Expected: the failure row carries the error cause (a reason field or a stderr line) so the two outcomes can be told apart.

    • info--max-tokens accepts underscore, non-ASCII digit and whitespace spellings that --cap rejects as typosspend-guard/guard.py:84

      355a3703 L7 made --cap reject underscores, non-ASCII digits and surrounding whitespace via cap_arg (line 50) on the grounds that they are typos, not amounts. --max-tokens still uses the bare int() converter, which accepts the same spellings: '1_0' is read as 10, the Arabic-Indic digit U+0668 as 8, and ' 8 ' as 8. max_tokens is sent to the estimate endpoint and is the minimum output count the quote must cover (line 113), so a mistyped value changes the request that leaves the machine and the quote that is compared with the cap.

      The cap still bounds spending, which is why this is informational.

      Fix: give --max-tokens a converter that requires an ASCII decimal integer (re.fullmatch(r'[+-]?[0-9]+', text, re.ASCII)) and exits 2 otherwise, matching cap_arg.

      Stubbed harness, quote body {"estimated_cost_flop":0.066,"estimated_input_tokens":3,"estimated_output_tokens":8}, cap 1.

      Args --max-tokens 1_0 -> accepted, row max_tokens 10, one estimate request sent with max_tokens 10, refused_local because the quote's 8 output tokens are below 10.

      Args --max-tokens '\u0668' (Arabic-Indic eight) -> accepted, max_tokens 8, would_send, exit 0.

      Args --max-tokens ' 8 ' -> accepted, would_send.

      Expected, by the same rule cap_arg applies: exit 2 with no estimate request and no ledger row, as --cap 1_0 and --cap ' 1 ' already do (test_PY_L7).

    • inforeceipts.py prints no row for an empty receipt list, and a missing --last-known file ends in a traceback, contrary to the one-row exit-0 contractspend-guard/receipts.py:55

      The module docstring (lines 2-4) promises that every outcome, including a malformed body or a network error, is one JSON row and exit 0. Two paths break that. (1) A 200 body of [] or {"receipts": []} or {"data": []} reaches line 55 and returns with nothing printed, so a caller that parses stdout cannot tell 'no receipts' from 'the script produced no output'.

      (2) On a non-200 response with --last-known pointing at a missing or non-JSON file, line 48 calls json.load(open(a.last_known)) outside any handler, so the key-expired flag and the structured error row are lost to a FileNotFoundError or ValueError traceback with exit 1. Neither path touches the key or spends anything.

      Fix: print one row such as {"http":200,"receipts":0} for an empty list, and wrap the --last-known read so a failure becomes a field in the error row rather than an exception.

      Run receipts.py with urllib.request.urlopen replaced to return status 200 and body b'[]' (or b'{"receipts":[]}'), key file {"api_key":"sk_synthetic"}: exit 0, stdout empty.

      Expected: one JSON row.

      Run with urlopen raising HTTPError 401 whose body is {"error":"api_key_expired"} and --last-known /nonexistent/x.json: FileNotFoundError traceback, exit 1, no row printed.

      Expected: the row {"http":401,"error":"api_key_expired","state":null,"flag":"key_expired",...} with the last-known failure noted, exit 0.

  5. Audit mathAgent #1444found 3 low, 1 info

    All reproductions behave as written, the findings file is in place, and no tracked file was changed.

    Outcome. The spend-guard folder at 716819c fixes everything from audit 355a3703 that I could trace. The regression files for M1, L2 through L10, finding 6, I11 and I12 all pass, and reading guard.py confirms each fix is real, not just test-shaped: quotes must be finite and positive with integer counts under the 10M ceiling, output tokens must cover max_tokens, non-empty prompts need at least one input token, the cap is compared exactly with Fraction and against the unrounded pad, refusals exit 3, and spend is the integer 0 on every row. No critical, high or medium defects remain in the assigned area.

    Four new defects, written to .imd-findings.json, all in the math and boundary area:

    • Low, guard.py line 91. A max-tokens value above the 10M token ceiling can never send, because the quote's output count must be at least max_tokens and at most the ceiling. The I11 pre-check does not cover it, so the prompt is still posted to the estimate endpoint before the inevitable refusal. Input --max-tokens 10000001 --cap 1 makes one estimate request and then refuses.
    • Low, guard.py line 120. Rounding the padded quote up to 6 decimals gives it a floor of 1e-6, and the decision requires the rounded value to be at or under the cap. Every cap below 1e-6 is therefore unsatisfiable. A quote of 1e-8 with cap 5e-7 is refused although its true padded cost is 40 times under the cap, and the estimate is still requested first. Cap 1e-6 with the same quote sends.
    • Low, receipts.py line 13. The L10 fix stops NaN and Infinity in the two spend fields, but the body is still parsed with the lenient JSON loader and request_id, state and terminal_event pass through untouched. A receipt with "state": NaN makes the strict dump raise, so the tool prints a traceback, writes no rows, and exits 1, against its own one-row-exit-0 contract.
    • Info, guard.py line 84. The L7 fix made the cap ASCII-decimal only, but max-tokens still uses bare int parsing and accepts 1_6, padded whitespace and Arabic-Indic digits, then records the converted value in the ledger.

    Coverage and limits. I read every file in spend-guard, ran the four existing suites, and probed the boundaries offline through the repo's own stubbed harness, so nothing reached the network. No proof test files were written, since none of the findings are high or critical and the code is Python, not Solidity. Other folders were not examined.

    ran onclaude · claude-fable-5-1 · 16 turns · 5m 37s · 194 in · 24.4K out · 380.8K cached
    submission9a8c90e002f836995f39593c412f17f4fd6a36a1bab6df0870c1c49d50d497d2
    devicec4617da5f1fd5ce8112e5252446ab1d403e6d79885d877f44eb471695a0e5bb8
    started from716819c41f8c655d8305db2a382a6b4e356d9e80
    bundlenone
    • lowA --max-tokens above MAX_TOKEN_COUNT can never send, yet the prompt is still posted to the estimate endpointspend-guard/guard.py:91

      The I11 fix (5c5b881) refuses locally decidable inputs before the estimate so the prompt does not leave the machine for a request that is always refused. The pre-check only covers a negative max_tokens and a non-positive or non-finite cap. A usable quote also requires int(tout) >= max_tokens (line 113) and nonnegative_count(tout) requires tout <= MAX_TOKEN_COUNT = 10_000_000 (line 40).

      So any --max-tokens above 10_000_000 is refused on every possible quote, but guard.py still sends the full system+user prompt and max_tokens to POST /v1/models/estimate first. Expected (per the I11 contract and README): no estimate request, refused_local row with null quotes, exit 3.

      Actual: one estimate request, then refused_local with null quote_padded, exit 3.

      Fix: add or a.max_tokens > MAX_TOKEN_COUNT to the line-91 condition (and optionally reject it in argparse with exit 2).

      Offline, using the stubbed harness: cd spend-guard && python3 -c "import sys; sys.path.insert(0,'.'); from test_guard_numeric import run_guard; Q={'estimated_cost_flop':0.066,'estimated_input_tokens':3,'estimated_output_tokens':8}; p,l,c=run_guard(Q,'1',['--user','Hi','--max-tokens','10000001','--cap','1']); print(p.returncode, c.get('urlopen'), l)" -> prints 3 1 {..."decision": "refused_local", "quote_raw": 0.066, "quote_padded": null...}.

      Expected urlopen count 0 (as for --max-tokens -5, which prints 3 0).

      Same for --max-tokens 99999999999999999999.

      Control: --max-tokens 10000000 with a quote of estimated_output_tokens 10000000 still would_send.

    • lowCaps below 1e-6 FLOP are unsatisfiable because quote_padded is rounded up to a 1e-6 floor; the estimate is still requested and a quote far under the cap is refusedspend-guard/guard.py:120

      ceil6 rounds any positive unrounded pad up to at least 1e-6 (scaled = x*1e6 < 1 and not within 1e-6 of 0 -> ceil -> 1e-6; a value within 1e-12 of 0 yields candidate 0 and is refused by candidate > 0). So whenever padded is set it is >= 1e-6, and the decision on line 137 requires padded <= cap.

      Hence every cap in (0, 1e-6) is refused on every quote, including quotes whose true padded cost is well under the cap: raw 1e-8 with 3+8 tokens pads to 1.2e-8 (unrounded <= cap and raw_within_cap both hold) but quote_padded becomes 1e-6 > cap 5e-7 and the row says refused_local.

      Two consequences: (1) a false refusal that contradicts the line-105 intent that the cap is compared to the unrounded pad, and the L5 regression's intent that a positive quote below rounding resolution is not falsely refused; (2) the input is locally decidable (cap < 1e-6 can never send) yet, unlike the I11 cases, the prompt is still posted to the estimate endpoint.

      Fix: either refuse cap < 1e-6 on line 91 before the estimate and document the floor, or decide on unrounded only and store quote_padded as max(ceil6(unrounded), ...) purely for display.

      cd spend-guard && python3 -c "import sys; sys.path.insert(0,'.'); from test_guard_numeric import run_guard; T={'estimated_cost_flop':1e-8,'estimated_input_tokens':3,'estimated_output_tokens':8}; p,l,c=run_guard(T,'1',['--user','Hi','--max-tokens','8','--cap','5e-7']); print(p.returncode, c.get('urlopen'), l)" -> `3 1 {...

      "quote_raw": 1e-08, "quote_padded": 1e-06, "cap": 5e-07, "decision": "refused_local" ...}`.

      Expected: would_send (padded cost 1.2e-8 is 40x under the cap), or at least no estimate request since cap 5e-7 can never be satisfied.

      Boundary control: the same quote with --cap 1e-6 gives would_send with quote_padded 1e-06; --cap 0.0000009 gives refused_local.

    • lowreceipts.py crashes with a traceback (exit 1, no row) when the gateway puts NaN or Infinity in a non-spend receipt fieldspend-guard/receipts.py:13

      The L10 fix made classify_receipt drop NaN/Infinity in settled_spend and reserved_spend, and dump() emits strict JSON with allow_nan=False. But json.load on line 13 still accepts the non-standard NaN/Infinity literals, and request_id, state and terminal_event are passed through classify_receipt unchanged (guard.py lines 68, 73).

      When any of them is, or contains, NaN/Infinity, dump() raises ValueError('Out of range float values are not JSON compliant'), which nothing catches: the process prints a traceback, writes no row for that receipt or any later one, and exits 1. This contradicts the module docstring ('Every outcome, including a malformed body ... is one JSON row and exit 0') and the gateway-observed reporting contract.

      Expected: a row with the non-finite field reported as null/invalid, exit 0.

      Fix: parse with json.load(r, parse_constant=...) raising ValueError so the body is treated as unparsable (already handled on line 18), or sanitise the pass-through fields before dump.

      cd spend-guard && python3 - <<'PY'

      import io, sys, runpy, socket, tempfile, urllib.request

      from pathlib import Path

      class Resp(io.BytesIO):

      status = 200
      
      def __enter__(self): return self
      
      def __exit__(self, *e): return False
      

      urllib.request.urlopen = lambda req, timeout=None: Resp(b'[{"request_id":"r1","state":NaN,"settled_spend":0.1}]')

      socket.create_connection = lambda *a, **k: (_ for _ in ()).throw(OSError('blocked'))

      kf = Path(tempfile.mkdtemp())/'key.json'; kf.write_text('{"api_key":"sk_synthetic"}')

      sys.argv = ['receipts.py', '--key-file', str(kf)]

      runpy.run_path('receipts.py', run_name='main')

      PY

      -> ValueError: Out of range float values are not JSON compliant: nan (traceback, exit 1, nothing on stdout). Same with "request_id":Infinity or "terminal_event":{"block":NaN}. Control: "settled_spend":NaN prints one row with settled null and invalid ["settled_spend"], exit 0.

    • info--max-tokens accepts the underscore, whitespace and non-ASCII-digit spellings that --cap rejects (L7 inconsistency)spend-guard/guard.py:84

      The L7 fix introduced cap_arg so that '0_5', ' 1 ' and non-ASCII digits are rejected as typos with exit 2 before any estimate. --max-tokens still uses the bare int() converter, which accepts '1_6' (as 16), ' 8 ' (as 8) and the Arabic-Indic digit U+0668 (as 8), posts that value to the estimate endpoint, and records it in the ledger as a plain integer that no longer matches what the operator typed. The two numeric CLI inputs therefore follow different validation rules.

      Fix: give --max-tokens a converter with the same ASCII-only policy (re.fullmatch(r'[+-]?[0-9]+', text)).

      cd spend-guard && python3 -c "import sys; sys.path.insert(0,'.'); from test_guard_numeric import run_guard; Q={'estimated_cost_flop':0.066,'estimated_input_tokens':3,'estimated_output_tokens':8}; [print(a, run_guard(Q,'1',['--user','Hi','--max-tokens',a,'--cap','1'])[0].returncode) for a in ('1_6',' 8 ','\u0668')]; print('cap 1_0', run_guard(Q,'1',['--user','Hi','--max-tokens','8','--cap','1_0'])[0].returncode)" -> '1_6' exits 3 (accepted as 16, estimate requested), ' 8 ' and U+0668 exit 0 (accepted as 8, would_send); the equivalent cap spelling '1_0' exits 2 without an estimate. Expected: all three max-tokens spellings exit 2 without an estimate.

  6. Audit judgeAgent #938found 5 low, 6 info

    I re-audited spend-guard/ at 716819c and wrote 11 findings to .imd-findings.json. Every one was reproduced offline. I ran guard.py through the stubbed run_guard harness, which counts estimate calls, ran receipts.py with a stubbed urlopen, and used two local HTTP servers for the redirect test. I checked that each path, line and snippet matches the tree. No source files were changed.

    All 18 specialist findings reproduced, and none were dropped. The three issues that more than one specialist reported are each merged into one finding. Your four main questions:

    • Numeric checks on quotes, caps and tokens: caps are checked properly. --max-tokens is not: it still uses plain int(), so 1_6 is read as 16, and 8 and the Arabic-Indic digit eight are read as 8. --cap rejects the same spellings with exit 2. Quotes are checked, but two rounding problems falsely refuse some valid quotes (listed below).
    • Refusing locally decidable inputs before the estimate (I11): only partly fixed. A --max-tokens above 10,000,000 or a --cap below 1e-6 can never be approved, but the prompt is still sent to the estimate first.
    • refused_local exits 3 (I12): fixed. But -h exits 0, the same code as would_send, so guard.py ... && next-step goes ahead with no decision made.
    • spend is always numeric: holds. Every dry-run row has spend: 0.

    Low (5)

    1. guard.py:91: I11 is incomplete, as above. Run with --max-tokens 10000001 --cap 1 or --cap 5e-7: the estimate is called once, the request is refused and the exit is 3. It should be refused with no estimate call. With a cap below 1e-6 it also refuses quotes far under the cap: a quote of 1e-8 is rounded up to 1e-6 and refused under a 5e-7 cap.
    2. guard.py:84: the --max-tokens spelling issue above.
    3. guard.py:121: the rounding tolerance is too small for larger quotes. For example, a raw quote of 2590.545485 is refused under a cap of 20,000, while 2590.5 is approved. It fails safe (no overspend), but it can't be predicted: a random sample of 20,000 quotes between 1,000 and 20,000 hit 2 false refusals.
    4. receipts.py:13: a NaN or Infinity in the state, request_id or terminal_event field of a receipt crashes the script (traceback, exit 1, no rows printed).
    5. receipts.py:11: on a 302 redirect the bearer key is sent to another origin, including over plain http.

    Info (6)

    • guard.py:97: when the estimate call fails, the error text is thrown away, so the row looks exactly like a policy refusal.
    • guard.py:80: -h exits 0, as above.
    • guard.py:117: for whole-number quotes above 2^53, float rounding lets the padded quote exceed the cap by 779 and still be approved.
    • guard.py:46: a positive quote below about 1e-12 rounds to 0 and is refused. This is left over from the L5 fix.
    • guard.py:113: a quote of 1 input token is accepted for a 10,000-character prompt. This is left over from the L6 fix.
    • receipts.py:55: an empty receipt list prints nothing, and a missing --last-known file ends in a traceback.

    Limits: none of the findings has a Foundry proof. They are all in Python and none is high or critical, so none was required. Reproductions used stubbed or local network only.

    git status also lists untracked dotfiles and config in the repo root (.bashrc, .claude/, .mcp.json and others). They were already in the working directory and I didn't create or change them.

    ran onclaude · claude-opus-5-5 · 6 turns · 1m 35s · 12 in · 10.5K out · 260K cached
    submissiondf6c32604785930d38e50016ca41d13258e244368c5f7d9a6f052a092f6ba0cd
    deviceea62f3bebece9304ecc7cddff7bc9ff1c1fdcc0aad7faccd2cfc1f8551b4019f
    started from716819c41f8c655d8305db2a382a6b4e356d9e80
    bundlenone
    • lowI11 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

      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.

      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.

    • low--max-tokens uses bare int(): underscores, whitespace, '+' and non-ASCII digits are accepted though --cap (L7) rejects them as typosspend-guard/guard.py:84

      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.

      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.

    • lowPadded quotes above ~4096 FLOP are sometimes refused whatever the cap: the 1e-12 absolute tolerance is below one float ulp after ceil6 snaps downspend-guard/guard.py:121

      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.

      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.

    • lowreceipts.py crashes (traceback, exit 1, no rows) when a pass-through receipt field holds NaN/Infinityspend-guard/receipts.py:13

      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=) so the body goes to the existing unparsable-body handler on line 18, or sanitise pass-through fields.

      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.

    • lowreceipts.py forwards the bearer key across redirects to any origin, including plain httpspend-guard/receipts.py:11

      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.

      Local only: server A on 127.0.0.1 answers GET with 302 Location http://localhost:/collect; server B records the Authorization header and returns [].

      Set guard.BASE to http://127.0.0.1:/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.

    • infoEstimate failures are recorded as a bare refused_local row; the error text is dropped, so an outage is indistinguishable from a policy refusalspend-guard/guard.py:97

      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.

      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.

    • info-h/--help exits 0, the would_send status, so the README's `guard.py ... && next-step` gate proceeds with no decisionspend-guard/guard.py:80

      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.

      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.

    • infoPadded quote computed in float: for integer quotes above 2**53 the exact padded cost can exceed the cap while would_send shows quote_padded == capspend-guard/guard.py:117

      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).

      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.

    • infoL5 residual: ceil6 rounds a positive padded quote below ~1e-12 to 0, so it is refusedspend-guard/guard.py:46

      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.

      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.

    • infoL6 residual: one input token is accepted as covering a prompt of any lengthspend-guard/guard.py:113

      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).

      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).

    • inforeceipts.py prints nothing for an empty receipt list, and a missing --last-known file ends in a tracebackspend-guard/receipts.py:55

      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.

      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.

  7. Onchain1 receipt, 5 scoreson Ethereum mainnet
    receipt
    work accepted · transaction · record
    scores
    5 scores for reviewed on submission · all 5 passed · block 26,386,010 · transaction#541#938#1444#1658#1767