-
Notifications
You must be signed in to change notification settings - Fork 70
Expand file tree
/
Copy pathaudit.py
More file actions
6713 lines (5853 loc) · 262 KB
/
Copy pathaudit.py
File metadata and controls
6713 lines (5853 loc) · 262 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# GENERATED STANDALONE ARTIFACT - DO NOT EDIT BY HAND.
# Source of truth: modular files listed in scripts/build-standalone.py.
# Regenerate after modular audit changes with:
# python3 scripts/build-standalone.py
# CI verifies this generated artifact plus key behavior regressions.
# source_sha256: 57cc4ddc3ccb3ed54e76e82dfc447c6e21322c2c03ab804625924e7ec655f245
# standalone_body_sha256: 3adf747824d6eeb9df837a1e6ae8fd6fe5aa861c57b6a422b9367e52ee0c630f
# END GENERATED STANDALONE HEADER
"""
API Relay Security Audit Tool v2.3 --- Standalone Edition
Generated curl-only artifact for users who want:
AUDIT_SCRIPT_REF=v2.3.0
curl -fsSL "https://raw.githubusercontent.com/toby-bridges/api-relay-audit/${AUDIT_SCRIPT_REF}/audit.py" -o audit.py
python audit.py --key YOUR_KEY --url https://relay.example.com/v1
The detection semantics below are generated from the modular source files
listed in scripts/build-standalone.py. Do not edit this file by hand; update
the modular source and run:
python3 scripts/build-standalone.py
The standalone keeps the product promise: no third-party Python packages,
only the standard library plus the curl executable for HTTP transport.
"""
import argparse
import hashlib
import json
import os
import re
import shlex
import statistics
import subprocess
import sys
import tempfile
import time
import uuid
from collections import Counter
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional, Tuple
from urllib.parse import urlparse
# ============================================================
# Transparent forensic logging
# ============================================================
"""Append-only JSONL forensic logger (arXiv:2604.08407 §7.3).
Records every API request made during an audit run with timestamp,
URL, SHA-256 of request/response bytes, status code, response
headers, and transport metadata. **Hash only, not body** — keeps
entries <=1.5 KB and avoids credential-at-rest risk.
TLS metadata capture is deferred to a follow-up commit; the
``tls_version`` and ``tls_cipher`` fields are always ``null`` for now.
"""
import hashlib
import json
import os
import sys
from datetime import datetime, timezone
def redact_error(error):
"""Strip response body content from error strings for safe logging.
Error strings like ``"HTTP 400: {body[:200]}"`` or
``"curl failed: {stderr[:200]}"`` may contain sensitive content
(leaked API keys, upstream URLs). This function keeps only the
error type and HTTP status, discarding everything after the first
colon. Other errors (exception messages, timeouts) pass through
unchanged.
Returns:
Redacted error string, or ``None`` if input is ``None``.
"""
if error is None:
return None
for prefix in ("HTTP ", "curl failed"):
if error.startswith(prefix):
colon = error.find(":")
if colon != -1:
return error[:colon]
return error
return error
def sha256hex(data):
"""Return the SHA-256 hex digest of *data* (bytes or str).
``None`` input returns ``None`` (e.g. when the response body
is not available due to a transport error).
"""
if data is None:
return None
if isinstance(data, str):
data = data.encode("utf-8")
return hashlib.sha256(data).hexdigest()
class TransparentLogger:
"""Append-only JSONL writer for forensic request logging.
Each call to :meth:`log_entry` writes one JSON line and flushes
immediately for crash-safety. Never raises — I/O errors are
printed to stderr so the audit is not interrupted by a full disk
or read-only path.
Usage::
logger = TransparentLogger("/tmp/audit.jsonl")
logger.log_entry({"timestamp": "...", "url": "..."})
logger.close()
"""
def __init__(self, path: str):
self._path = path
# Create parent directories if they don't exist (MEDIUM fix).
parent = os.path.dirname(os.path.abspath(path))
os.makedirs(parent, exist_ok=True)
self._f = open(path, "a", encoding="utf-8")
def log_entry(self, entry: dict) -> None:
"""Serialise *entry* as a single JSON line and flush."""
try:
self._f.write(json.dumps(entry, ensure_ascii=False) + "\n")
self._f.flush()
except Exception as e:
print(f" [transparent-log] write error: {e}", file=sys.stderr)
def close(self) -> None:
"""Close the underlying file handle (idempotent)."""
try:
self._f.close()
except Exception:
pass
# ============================================================
# Stream integrity signals and verdicts
# ============================================================
"""Stream integrity signals for Step 10 SSE-level relay tampering detection.
This module provides the data structures that capture what an
Anthropic-format streaming response looked like at the SSE event
layer. The actual verdict logic (:func:`analyze_stream`) is added in
a follow-up commit (Sub-PR 2); this commit ships the dataclass plus
constants so :meth:`api_relay_audit.client.APIClient.stream_call`
has something to populate.
## Detection approach
A malicious relay that rewrites or proxies Claude's streaming
responses can be caught at three distinct layers, even if the final
text the user sees looks correct:
1. **SSE event whitelist.** Anthropic's stream schema uses exactly
7 event types (see :data:`KNOWN_SSE_EVENT_TYPES`). An unknown
event type in the stream is a strong fingerprint of a relay that
is injecting or rewriting events. Sub-PR 2's ``analyze_stream``
penalises any unknown event.
2. **Usage-field monotonicity.** The ``message_start`` event carries
an ``input_tokens`` count; subsequent ``message_delta`` events
carry incremental ``output_tokens`` and a reiteration of
``input_tokens``. A relay that rewrites usage (to under-bill the
caller or hide a model downgrade) often fails these invariants:
``output_tokens`` may go non-monotonic, or ``input_tokens`` may
mysteriously shift between events.
3. **Thinking block signature consistency.** Claude Opus/Sonnet 4.6
extended-thinking responses emit ``signature_delta`` events whose
``signature`` field must be non-empty. A relay that degrades to
a non-thinking model and fakes the surrounding stream events may
leave the signatures empty. :attr:`StreamSignals.empty_signature_delta_count`
counts these.
## Attribution
The threat model and the specific list of observable signals is
inspired by hvoy.ai's ``zzsting88/relayAPI`` ``claude_detector.py``
``StreamSignals`` dataclass (verified against the source on
2026-04-11). The upstream repository has no ``LICENSE`` file, so
this module is an independent clean-room reimplementation:
- The field NAMES (``event_types``, ``message_start_model``,
``empty_signature_delta_count`` etc.) overlap with hvoy.ai's
because they describe the same Anthropic SSE schema — schema
field names and protocol event types are not copyrightable.
- The field TYPES and default factories are our own choices.
- The scoring / verdict logic in Sub-PR 2 will be tri-state
(``clean`` / ``anomaly`` / ``inconclusive``), NOT hvoy.ai's
weighted 0-100 score model.
See the ``reference_hvoy_relayapi`` memory file for the full
verification and the list of things we chose NOT to port
(knowledge cutoff probe, Claude Code CLI header impersonation,
``"null"`` text-block request body fingerprint).
Reference: Liu, Shou, Wen, Chen, Fang, Feng, *"Your Agent Is Mine:
Measuring Malicious Intermediary Attacks on the LLM Supply Chain"*,
arXiv:2604.08407, section 4.2. SSE whitelist / usage monotonicity
/ signature consistency are AC-1-class detections at the transport
layer.
"""
from dataclasses import dataclass, field
from typing import List, Optional
# The 7 known Anthropic SSE event types. Anything else in an
# ``event_types`` list is an "unknown event" — a potential signal
# that a relay is injecting or rewriting SSE events. Sourced from
# reading ``claude_detector.py`` lines 369-377 of
# ``zzsting88/relayAPI`` on 2026-04-11.
KNOWN_SSE_EVENT_TYPES = frozenset({
"ping",
"message_start",
"content_block_start",
"content_block_delta",
"content_block_stop",
"message_delta",
"message_stop",
})
@dataclass
class StreamSignals:
"""Captures what a streaming Anthropic response looked like at
the SSE event layer.
Populated by :meth:`api_relay_audit.client.APIClient.stream_call`
during the request; consumed by
:func:`analyze_stream` (added in Sub-PR 2) afterwards.
All fields default to the "nothing observed" value so that a
stream that errored out still produces a valid, serialisable
signals object. Downstream consumers must check
:attr:`transport_error` before drawing conclusions about
"clean vs anomalous" — an empty signals object with an error
should be reported as *inconclusive*, not *clean*.
Attributes:
event_types: Ordered list of every SSE event type observed
in the stream, including unknown types. Used for the
whitelist check.
content_block_types: Types observed in
``content_block_start`` events (e.g. ``"text"`` or
``"thinking"``), in arrival order.
delta_types: Types observed in ``content_block_delta``
events (e.g. ``"text_delta"``, ``"thinking_delta"``,
``"signature_delta"``), in arrival order.
has_message_start: True iff at least one ``message_start``
event was observed.
has_content_block_start: True iff at least one
``content_block_start`` event was observed.
has_content_block_delta: True iff at least one
``content_block_delta`` event was observed.
has_message_delta: True iff at least one ``message_delta``
event was observed.
has_message_stop: True iff at least one ``message_stop``
event was observed.
has_text_delta: True iff at least one ``text_delta`` inside
a ``content_block_delta`` was observed.
thinking_start_seen: True iff a ``content_block_start`` with
``content_block.type == "thinking"`` was observed.
thinking_delta_seen: True iff at least one ``thinking_delta``
was observed inside a ``content_block_delta``.
message_start_model: The ``message.model`` field from the
first ``message_start`` event, or ``None`` if missing.
A relay that routes ``claude-*`` to a non-Claude model
often leaks the truth here.
input_tokens: The ``input_tokens`` value from the first
``message_start`` event's ``usage`` block, or ``None``.
message_delta_input_tokens_samples: Every ``input_tokens``
value observed in ``message_delta`` events. Used to
detect rewriting — these should all equal
:attr:`input_tokens`.
output_tokens_samples: Every ``output_tokens`` value
observed in ``message_delta`` events, in arrival order.
Used to check monotonicity (each sample should be
greater than or equal to the previous one).
empty_signature_delta_count: Number of ``signature_delta``
events with an empty or whitespace-only signature field.
> 0 is a thinking-block downgrade signal.
transport_error: Non-``None`` iff the stream could not be
opened or parsed cleanly (connection error, non-200
response status, timeout). Downstream consumers should
treat this as *inconclusive*, never *clean*.
total_duration_seconds: Wall clock time from request start
to stream close. Useful for detecting buffered-rewriter
relays that delay the entire response.
raw_event_count: Total number of events parsed from the
stream, including unknown types. Zero means no data was
received at all — another inconclusive signal.
"""
# Ordered event type sequence (for whitelist check)
event_types: List[str] = field(default_factory=list)
# Content block types observed in content_block_start events
content_block_types: List[str] = field(default_factory=list)
# Delta types observed in content_block_delta events
delta_types: List[str] = field(default_factory=list)
# Boolean presence flags for convenient queries
has_message_start: bool = False
has_content_block_start: bool = False
has_content_block_delta: bool = False
has_message_delta: bool = False
has_message_stop: bool = False
has_text_delta: bool = False
thinking_start_seen: bool = False
thinking_delta_seen: bool = False
# Identity and usage signals
message_start_model: Optional[str] = None
input_tokens: Optional[int] = None
message_delta_input_tokens_samples: List[int] = field(default_factory=list)
output_tokens_samples: List[int] = field(default_factory=list)
# Thinking block anomaly counters
empty_signature_delta_count: int = 0
# Transport and timing
transport_error: Optional[str] = None
total_duration_seconds: Optional[float] = None
raw_event_count: int = 0
# ---------------------------------------------------------------------------
# Verdict analysis (Sub-PR 2)
# ---------------------------------------------------------------------------
# Cap for how many unknown event types we report in findings output.
# hvoy.ai's claude_detector.py uses -6 as a numeric penalty cap on the
# SSE shape score; we don't use numeric scoring but we cap the list
# length at 6 to keep report output bounded even on pathological relays.
MAX_UNKNOWN_EVENTS_REPORTED = 6
def _check_usage_monotonic(signals: "StreamSignals") -> bool:
"""``output_tokens_samples`` must be monotonically non-decreasing.
An empty list is vacuously monotonic; a single-element list is too.
"""
samples = signals.output_tokens_samples
if len(samples) <= 1:
return True
for i in range(1, len(samples)):
if samples[i] < samples[i - 1]:
return False
return True
def _check_usage_consistent(signals: "StreamSignals") -> bool:
"""``message_delta`` ``input_tokens`` samples must agree with the
``input_tokens`` reported by the initial ``message_start``.
A relay that rewrites usage (to hide a model downgrade or
under-bill the caller) often fails this invariant. Returns True
if consistent (or if there's nothing to compare)."""
if signals.input_tokens is None:
return True
if not signals.message_delta_input_tokens_samples:
return True
return all(
sample == signals.input_tokens
for sample in signals.message_delta_input_tokens_samples
)
def _check_stream_model(signals: "StreamSignals") -> bool:
"""``message_start.message.model`` should contain ``"claude"`` for
an Anthropic-format streaming response.
Missing ``message_start.message.model`` is itself suspicious once the
relay has emitted substantive events: a middleware can hide a model
downgrade simply by stripping the field instead of exposing a
non-Claude upstream name. The "no events received" branch is handled
earlier in :func:`analyze_stream` as inconclusive.
"""
if not signals.message_start_model:
return False
return "claude" in signals.message_start_model.lower()
def analyze_stream(signals: "StreamSignals") -> dict:
"""Analyze a populated :class:`StreamSignals` for integrity anomalies.
Returns a dict with these keys:
- ``verdict``: ``"clean"`` / ``"anomaly"`` / ``"inconclusive"``
- ``event_shape``: ``"pass"`` / ``"partial"`` / ``"weak"``
- ``unknown_events``: list of unknown event types (capped at
:data:`MAX_UNKNOWN_EVENTS_REPORTED`)
- ``usage_monotonic``: bool
- ``usage_consistent``: bool
- ``signature_valid``: bool
- ``stream_model_name``: ``message_start.message.model`` or ``None``
- ``stream_model_is_claude``: bool
- ``findings``: list of human-readable reasons (empty on clean)
Verdict priority (first match wins):
1. **inconclusive** — ``transport_error`` is non-None, OR
``raw_event_count == 0``, OR only ``ping`` events were seen
(a stream that opens but never sends ``message_start`` is
either broken or non-Anthropic; we have no basis to judge).
2. **anomaly** — at least one of: unknown event types present,
usage non-monotonic, usage inconsistent, empty
``signature_delta`` count > 0, stream model name non-Claude.
3. **clean** — none of the above triggered.
The function is pure and deterministic: identical input always
produces identical output. No I/O.
"""
# Priority 1: inconclusive via transport error
if signals.transport_error:
return {
"verdict": "inconclusive",
"event_shape": "weak",
"unknown_events": [],
"usage_monotonic": True,
"usage_consistent": True,
"signature_valid": True,
"stream_model_name": signals.message_start_model,
"stream_model_is_claude": True,
"findings": [f"Stream transport error: {signals.transport_error}"],
}
# Priority 1b: inconclusive via no substantive events
non_ping_events = [e for e in signals.event_types if e != "ping"]
if signals.raw_event_count == 0 or not non_ping_events:
return {
"verdict": "inconclusive",
"event_shape": "weak",
"unknown_events": [],
"usage_monotonic": True,
"usage_consistent": True,
"signature_valid": True,
"stream_model_name": signals.message_start_model,
"stream_model_is_claude": True,
"findings": [
"Stream opened but produced no non-ping events — the "
"relay is either broken or does not speak Anthropic SSE"
],
}
# Gather all anomaly signals (no early return — callers benefit
# from knowing every reason for the verdict).
unknown_events = sorted({
e for e in signals.event_types if e not in KNOWN_SSE_EVENT_TYPES
})
unknown_events_capped = unknown_events[:MAX_UNKNOWN_EVENTS_REPORTED]
usage_monotonic = _check_usage_monotonic(signals)
usage_consistent = _check_usage_consistent(signals)
signature_valid = signals.empty_signature_delta_count == 0
stream_model_is_claude = _check_stream_model(signals)
findings = []
if unknown_events:
suffix = " (+more, capped)" if len(unknown_events) > MAX_UNKNOWN_EVENTS_REPORTED else ""
findings.append(
f"Stream contained {len(unknown_events)} unknown SSE event "
f"type(s): {', '.join(unknown_events_capped)}{suffix}"
)
if not usage_monotonic:
findings.append(
"output_tokens samples across message_delta events went "
"backwards at least once — a relay is rewriting usage fields"
)
if not usage_consistent:
findings.append(
f"input_tokens at message_start ({signals.input_tokens}) "
f"disagrees with message_delta samples "
f"({signals.message_delta_input_tokens_samples}) — usage rewrite"
)
if not signature_valid:
findings.append(
f"{signals.empty_signature_delta_count} signature_delta event(s) "
"had empty signatures — thinking block downgrade or rewriter"
)
if not stream_model_is_claude:
if signals.message_start_model:
findings.append(
f"Stream's message_start.message.model = "
f"{signals.message_start_model!r} does not contain 'claude' — "
"relay may be routing to a substitute model"
)
else:
findings.append(
"Stream omitted message_start.message.model entirely — "
"relay may be stripping model identity to hide a downgrade"
)
anomaly = bool(
unknown_events
or not usage_monotonic
or not usage_consistent
or not signature_valid
or not stream_model_is_claude
)
# Event-shape classification (human-readable summary for reporting).
shape_flags_seen = sum([
signals.has_message_start,
signals.has_content_block_start,
signals.has_content_block_delta,
signals.has_message_delta,
signals.has_message_stop,
])
if shape_flags_seen >= 4 and signals.has_text_delta and not unknown_events:
event_shape = "pass"
elif shape_flags_seen >= 2:
event_shape = "partial"
else:
event_shape = "weak"
return {
"verdict": "anomaly" if anomaly else "clean",
"event_shape": event_shape,
"unknown_events": unknown_events_capped,
"usage_monotonic": usage_monotonic,
"usage_consistent": usage_consistent,
"signature_valid": signature_valid,
"stream_model_name": signals.message_start_model,
"stream_model_is_claude": stream_model_is_claude,
"findings": findings,
}
# ============================================================
# Standalone curl transport facade
# ============================================================
"""Internal HTTP transport helpers for the modular API client.
This is deliberately an internal facade-preserving extraction: APIClient
still owns format detection, logging, and fallback policy. These helpers
only centralize the low-level httpx/curl request mechanics.
"""
import json
import os
import subprocess
import tempfile
from urllib.parse import urlparse
LOOPBACK_NO_PROXY = "localhost,127.0.0.1,::1"
LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
def curl_loopback_no_proxy_args(url: str) -> list:
"""Return curl args that keep loopback URLs out of proxy env routing."""
if urlparse(url).hostname in LOOPBACK_HOSTS:
return ["--noproxy", LOOPBACK_NO_PROXY]
return []
def curl_post_json(url: str, headers: dict, body: dict, timeout: int,
subprocess_module=subprocess) -> dict:
"""POST JSON through curl while keeping headers out of argv.
Headers are passed through ``--config -`` so credentials do not show up
in process listings. The JSON body is written to a short-lived file and
sent via ``--data-binary @file`` so very large prompts do not hit Windows'
32 KB command-line limit.
"""
body_path = None
try:
with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", delete=False, prefix="api-relay-body-", suffix=".json"
) as tmp:
json.dump(body, tmp)
body_path = tmp.name
cmd = ["curl", "-sk", *curl_loopback_no_proxy_args(url), "-X", "POST", url,
"--max-time", str(timeout), "--config", "-", "--data-binary", f"@{body_path}"]
config = "\n".join(f'header = "{k}: {v}"' for k, v in headers.items())
r = subprocess_module.run(cmd, capture_output=True, text=True, input=config,
timeout=timeout + 10)
finally:
if body_path:
try:
os.unlink(body_path)
except OSError:
pass
if r.returncode != 0:
raise RuntimeError(f"curl failed: {r.stderr[:200]}")
return json.loads(r.stdout)
def curl_get_json_data(url: str, headers: dict, timeout: int = 15,
subprocess_module=subprocess) -> list:
"""GET JSON through curl and return the top-level ``data`` list."""
cmd = ["curl", "-sk", *curl_loopback_no_proxy_args(url), url,
"--max-time", str(timeout), "--config", "-"]
config = "\n".join(f'header = "{k}: {v}"' for k, v in headers.items())
r = subprocess_module.run(cmd, capture_output=True, text=True, input=config,
timeout=timeout + 10)
if r.returncode != 0:
return []
return json.loads(r.stdout).get("data", [])
def curl_raw_request(method: str, url: str, headers: dict, body: bytes,
content_type: str, timeout: int, parser,
subprocess_module=subprocess) -> dict:
"""Raw request through curl and parse ``curl -i`` output with ``parser``."""
all_headers = {**headers, "content-type": content_type}
cmd = ["curl", "-sk", *curl_loopback_no_proxy_args(url), "-i", "-X", method, url,
"--max-time", str(timeout), "--data-binary", "@-"]
for k, v in all_headers.items():
cmd.extend(["-H", f"{k}: {v}"])
try:
r = subprocess_module.run(cmd, capture_output=True, input=body,
timeout=timeout + 10)
if r.returncode != 0:
err = r.stderr.decode("utf-8", errors="replace")[:200]
return {"status": 0, "headers": {}, "body": "",
"error": f"curl failed: {err}"}
output = r.stdout.decode("utf-8", errors="replace")
return parser(output)
except Exception as e:
return {"status": 0, "headers": {}, "body": "", "error": str(e)}
def httpx_post_json(url: str, headers: dict, body: dict, timeout: int) -> dict:
"""Standalone compatibility wrapper: use curl for the modular httpx slot."""
return curl_post_json(url, headers, body, timeout)
def httpx_get_json_data(url: str, headers: dict, timeout: int = 15):
"""Standalone compatibility wrapper: GET JSON through curl -i."""
cmd = [
"curl", "-sk", *curl_loopback_no_proxy_args(url),
"-i", url, "--max-time", str(timeout), "--config", "-"
]
config = "\n".join(f'header = "{k}: {v}"' for k, v in headers.items())
r = subprocess.run(
cmd,
capture_output=True,
text=True,
input=config,
timeout=timeout + 10,
)
if r.returncode != 0:
return 0, [], "", {}
parsed = _parse_curl_i_output(r.stdout)
status = parsed.get("status", 0)
text = parsed.get("body", "")
data = []
if status == 200:
try:
data = json.loads(text).get("data", [])
except Exception:
data = []
return status, data, text, parsed.get("headers", {})
def httpx_raw_request(method: str, url: str, headers: dict, body: bytes,
content_type: str, timeout: int) -> dict:
"""Standalone compatibility wrapper: raw request through curl -i."""
return curl_raw_request(
method,
url,
headers,
body,
content_type,
timeout,
parser=_parse_curl_i_output,
)
class _StandaloneTransport:
curl_loopback_no_proxy_args = staticmethod(curl_loopback_no_proxy_args)
curl_post_json = staticmethod(curl_post_json)
httpx_post_json = staticmethod(httpx_post_json)
curl_get_json_data = staticmethod(curl_get_json_data)
httpx_get_json_data = staticmethod(httpx_get_json_data)
curl_raw_request = staticmethod(curl_raw_request)
httpx_raw_request = staticmethod(httpx_raw_request)
_transport = _StandaloneTransport()
# ============================================================
# API client
# ============================================================
"""
Shared API client with auto-detection (Anthropic / OpenAI) and curl fallback.
Eliminates duplicated API calling logic across scripts.
"""
import hashlib
import json
import subprocess
import time
from datetime import datetime, timezone
def _extract_anthropic_text(content) -> str:
"""Concatenate text from every text block in an Anthropic ``content`` array.
Anthropic responses may lead with a ``thinking`` or ``tool_use`` block
when extended thinking or tool use is enabled. The old ``content[0].text``
shortcut returned ``""`` in those cases, which then cascaded into auto-
detection flipping to the OpenAI probe and every downstream text-based
step (token injection, identity, jailbreak, prompt extraction, tool
substitution) seeing an empty response and silently reporting clean.
"""
if not isinstance(content, list):
return ""
parts = []
for block in content:
if not isinstance(block, dict):
continue
btype = block.get("type")
if btype is not None and btype != "text":
continue
text = block.get("text")
if isinstance(text, str):
parts.append(text)
return "".join(parts)
def _parse_curl_i_output(output: str) -> dict:
"""Parse ``curl -i`` (or ``curl -sk -i``) stdout into a response dict.
Handles HTTP/1.x and HTTP/2 status lines and normalises ``\\r\\n`` line
endings. A leading ``HTTP/X 100 Continue`` preface is skipped so the
final status is surfaced.
Returns ``{"status": int, "headers": dict, "body": str, "error": str|None}``
where ``status == 0`` indicates a parse failure (``error`` set to a
short diagnostic string).
"""
if not output:
return {"status": 0, "headers": {}, "body": "", "error": "empty curl output"}
# Normalise line endings so the \n\n separator is reliable.
text = output.replace("\r\n", "\n")
# Split into header block / body on the first blank line.
sep_idx = text.find("\n\n")
if sep_idx == -1:
return {"status": 0, "headers": {}, "body": text, "error": "no header/body separator"}
headers_block = text[:sep_idx]
body_block = text[sep_idx + 2:]
# Skip any ``HTTP/X 100 Continue`` preface followed by its own blank line.
while headers_block.split("\n", 1)[0].find(" 100 ") != -1:
next_sep = body_block.find("\n\n")
if next_sep == -1:
return {"status": 0, "headers": {}, "body": body_block,
"error": "unterminated 100 Continue preface"}
headers_block = body_block[:next_sep]
body_block = body_block[next_sep + 2:]
lines = headers_block.split("\n")
status_line = lines[0] if lines else ""
# "HTTP/1.1 404 Not Found" or "HTTP/2 404"
parts = status_line.split(" ", 2)
status = 0
if len(parts) >= 2:
try:
status = int(parts[1])
except ValueError:
status = 0
headers = {}
for line in lines[1:]:
if ":" in line:
k, _, v = line.partition(":")
headers[k.strip()] = v.strip()
return {
"status": status,
"headers": headers,
"body": body_block,
"error": None,
}
def _populate_stream_signals(event: dict, signals: StreamSignals) -> None:
"""Dispatch a single parsed SSE event dict into a StreamSignals.
Mutates ``signals`` in place. Never raises — malformed fields
are silently ignored so a broken event anywhere in the stream
does not abort the rest of the parse.
This helper lives at module scope (rather than on ``APIClient``)
so it can be unit-tested without instantiating a client or
touching the network.
"""
signals.raw_event_count += 1
event_type = event.get("type", "")
if isinstance(event_type, str) and event_type:
signals.event_types.append(event_type)
if event_type == "message_start":
signals.has_message_start = True
message = event.get("message", {})
if isinstance(message, dict):
model_name = message.get("model")
if isinstance(model_name, str):
signals.message_start_model = model_name
usage = message.get("usage", {})
if isinstance(usage, dict):
input_tokens = usage.get("input_tokens")
if isinstance(input_tokens, int):
signals.input_tokens = input_tokens
elif event_type == "content_block_start":
signals.has_content_block_start = True
block = event.get("content_block", {})
if isinstance(block, dict):
block_type = block.get("type", "")
if isinstance(block_type, str) and block_type:
signals.content_block_types.append(block_type)
if block.get("type") == "thinking":
signals.thinking_start_seen = True
elif event_type == "content_block_delta":
signals.has_content_block_delta = True
delta = event.get("delta", {})
if isinstance(delta, dict):
delta_type = delta.get("type")
if isinstance(delta_type, str) and delta_type:
signals.delta_types.append(delta_type)
if delta_type == "text_delta":
signals.has_text_delta = True
elif delta_type == "thinking_delta":
signals.thinking_delta_seen = True
elif delta_type == "signature_delta":
signature = delta.get("signature")
if isinstance(signature, str) and not signature.strip():
signals.empty_signature_delta_count += 1
elif event_type == "message_delta":
signals.has_message_delta = True
usage = event.get("usage", {})
if isinstance(usage, dict):
input_tokens = usage.get("input_tokens")
if isinstance(input_tokens, int):
signals.message_delta_input_tokens_samples.append(input_tokens)
output_tokens = usage.get("output_tokens")
if isinstance(output_tokens, int):
signals.output_tokens_samples.append(output_tokens)
elif event_type == "message_stop":
signals.has_message_stop = True
# v1.7.1 safety valve: cap the SSE parser buffer so a malformed/
# malicious relay that sends a huge chunk without newlines cannot
# grow memory unboundedly. 1 MB is comfortably above any real
# Anthropic event size (biggest thinking blocks are ~100 KB).
MAX_STREAM_BUFFER_BYTES = 1024 * 1024
CURL_STATUS_SENTINEL = "__CODEX_HTTP_STATUS__:"
def _process_sse_line(line: str, signals: StreamSignals) -> bool:
"""Parse a single SSE line and update ``signals``.
Returns ``True`` if the terminal ``data: [DONE]`` sentinel was
seen (the caller should stop parsing), ``False`` otherwise.
Skips lines that don't start with ``data: `` (e.g. ``event: ``
or ``id: `` lines used by some SSE implementations). Silently
ignores malformed JSON so one broken event does not abort the
rest of the stream.
"""
line = line.strip()
if not line.startswith("data: "):
return False
data = line[6:]
if data == "[DONE]":
return True
try:
event = json.loads(data)
except json.JSONDecodeError:
return False
if isinstance(event, dict):
_populate_stream_signals(event, signals)
return False
def _parse_sse_stream(byte_iterator, signals: StreamSignals,
hasher=None) -> None:
"""Consume a byte iterator and populate ``signals`` with every
SSE event it contains.
Handles:
- Multi-byte chunks that split in the middle of a UTF-8 sequence
(uses ``errors="ignore"`` on decode so we don't wedge)
- Multiple events in a single chunk
- A single event split across multiple chunks (buffered until
a newline is seen)
- A terminal ``data: [DONE]`` sentinel
- Malformed JSON lines (skipped silently, do not abort the
rest of the stream)
- Empty lines / non-``data: `` lines (skipped)
- Streams that end without a trailing newline — the final
residual line is flushed after the iterator exhausts (v1.7.1)
- Adversarial streams that send >1 MB without a newline —
``transport_error`` is set and parsing bails (v1.7.1)
Args:
hasher: Optional ``hashlib`` hash object. When not None, every
raw chunk is fed to ``hasher.update()`` for incremental
SHA-256 of the full stream (v1.7.7 transparent-log support).
Never raises. Mutates ``signals`` in place.
"""
buffer = ""
for chunk in byte_iterator:
# v1.7.7: incremental stream hashing for transparent-log.
if hasher is not None:
if isinstance(chunk, (bytes, bytearray)):
hasher.update(chunk)
else:
hasher.update(chunk.encode("utf-8", errors="ignore"))
if isinstance(chunk, (bytes, bytearray)):
buffer += chunk.decode("utf-8", errors="ignore")
else:
buffer += chunk
# v1.7.1: safety valve against unbounded buffer growth on
# adversarial or broken streams. A compliant relay will have
# drained the buffer via newline splits before reaching this
# check; only an unterminated line can push past the cap.
if len(buffer) > MAX_STREAM_BUFFER_BYTES:
signals.transport_error = (
f"SSE stream buffer exceeded {MAX_STREAM_BUFFER_BYTES} bytes "
"(unterminated line — possible malformed or malicious stream)"
)
return
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
if _process_sse_line(line, signals):
return # [DONE] sentinel
# v1.7.1: flush any residual final line if the stream ended
# without a trailing newline (broken or truncated relay).
if buffer:
_process_sse_line(buffer, signals)
class APIClient:
"""Unified API client that auto-detects Anthropic vs OpenAI format.
On the first ``call()``, the client tries the Anthropic native message
format and, if that fails, falls back to the OpenAI-compatible
``/chat/completions`` endpoint. If a Python-level SSL error is
encountered, the transport silently switches to a ``curl -sk``
subprocess so the audit can continue against self-signed relays.
Attributes:
base_url: Root URL of the relay (trailing slash stripped).
api_key: Bearer / x-api-key token.
model: Model identifier forwarded to the relay.
timeout: Per-request timeout in seconds.
verbose: If ``True``, diagnostic messages are printed to stdout.
"""
def __init__(self, base_url: str, api_key: str, model: str,
timeout: int = 120, verbose: bool = True):
"""Initialise the client.
Args:
base_url: Root URL of the API relay (e.g. ``"https://relay.example.com"``).
api_key: Authentication token sent as ``x-api-key`` (Anthropic)
or ``Authorization: Bearer`` (OpenAI).
model: Model identifier to include in every request body.
timeout: HTTP / curl timeout in seconds. Defaults to 120.
verbose: Whether to print diagnostic log lines. Defaults to True.