shadowshield 0.6.0__tar.gz → 0.6.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. {shadowshield-0.6.0 → shadowshield-0.6.2}/PKG-INFO +50 -7
  2. {shadowshield-0.6.0 → shadowshield-0.6.2}/README.md +47 -6
  3. {shadowshield-0.6.0 → shadowshield-0.6.2}/docs/BENCHMARKS.md +57 -4
  4. {shadowshield-0.6.0 → shadowshield-0.6.2}/docs/PRODUCTION_READINESS.md +42 -12
  5. shadowshield-0.6.2/docs/RELEASING.md +109 -0
  6. {shadowshield-0.6.0 → shadowshield-0.6.2}/docs/index.md +1 -1
  7. {shadowshield-0.6.0 → shadowshield-0.6.2}/pyproject.toml +3 -1
  8. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/__init__.py +1 -1
  9. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/_security.py +31 -0
  10. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/cli.py +95 -6
  11. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/control.py +472 -77
  12. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/core/shield.py +24 -0
  13. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/detectors/base.py +13 -0
  14. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/detectors/exfiltration.py +11 -0
  15. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/detectors/jailbreak.py +30 -11
  16. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/detectors/prompt_injection.py +23 -1
  17. shadowshield-0.6.2/src/shadowshield/detectors/transformer.py +219 -0
  18. shadowshield-0.6.2/src/shadowshield/detectors/vector.py +254 -0
  19. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/eval/__init__.py +2 -0
  20. shadowshield-0.6.2/src/shadowshield/eval/data/generalization_benchmark_v1.jsonl +30 -0
  21. shadowshield-0.6.2/src/shadowshield/eval/data/generalization_benchmark_v2.jsonl +20 -0
  22. shadowshield-0.6.2/src/shadowshield/eval/data/generalization_benchmark_v3.jsonl +40 -0
  23. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/eval/dataset.py +21 -6
  24. shadowshield-0.6.2/src/shadowshield/reporter.py +250 -0
  25. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/server.py +13 -1
  26. shadowshield-0.6.2/tests/test_adversarial.py +19 -0
  27. shadowshield-0.6.2/tests/test_cli.py +107 -0
  28. {shadowshield-0.6.0 → shadowshield-0.6.2}/tests/test_control.py +412 -14
  29. {shadowshield-0.6.0 → shadowshield-0.6.2}/tests/test_detectors.py +51 -1
  30. shadowshield-0.6.2/tests/test_generalization_benchmarks.py +138 -0
  31. {shadowshield-0.6.0 → shadowshield-0.6.2}/tests/test_server.py +100 -0
  32. shadowshield-0.6.2/tests/test_site_security.py +107 -0
  33. {shadowshield-0.6.0 → shadowshield-0.6.2}/tests/test_spans.py +1 -1
  34. shadowshield-0.6.2/tests/test_telemetry.py +324 -0
  35. shadowshield-0.6.2/tests/test_transformer.py +345 -0
  36. shadowshield-0.6.2/tests/test_vector.py +478 -0
  37. shadowshield-0.6.0/docs/RELEASING.md +0 -66
  38. shadowshield-0.6.0/src/shadowshield/detectors/transformer.py +0 -115
  39. shadowshield-0.6.0/src/shadowshield/detectors/vector.py +0 -137
  40. shadowshield-0.6.0/src/shadowshield/reporter.py +0 -151
  41. shadowshield-0.6.0/tests/test_adversarial.py +0 -25
  42. shadowshield-0.6.0/tests/test_cli.py +0 -48
  43. shadowshield-0.6.0/tests/test_telemetry.py +0 -112
  44. shadowshield-0.6.0/tests/test_transformer.py +0 -129
  45. shadowshield-0.6.0/tests/test_vector.py +0 -159
  46. {shadowshield-0.6.0 → shadowshield-0.6.2}/.gitignore +0 -0
  47. {shadowshield-0.6.0 → shadowshield-0.6.2}/LICENSE +0 -0
  48. {shadowshield-0.6.0 → shadowshield-0.6.2}/docs/CODE_REVIEW.md +0 -0
  49. {shadowshield-0.6.0 → shadowshield-0.6.2}/docs/COMPARISON.md +0 -0
  50. {shadowshield-0.6.0 → shadowshield-0.6.2}/docs/MARKET_LANDSCAPE.md +0 -0
  51. {shadowshield-0.6.0 → shadowshield-0.6.2}/docs/NEXT_STEPS.md +0 -0
  52. {shadowshield-0.6.0 → shadowshield-0.6.2}/docs/OWASP_LLM_TOP10.md +0 -0
  53. {shadowshield-0.6.0 → shadowshield-0.6.2}/docs/PLAN_REVIEW.md +0 -0
  54. {shadowshield-0.6.0 → shadowshield-0.6.2}/docs/REPORTER_SDK_SPEC.md +0 -0
  55. {shadowshield-0.6.0 → shadowshield-0.6.2}/docs/SAAS_STRATEGY.md +0 -0
  56. {shadowshield-0.6.0 → shadowshield-0.6.2}/docs/UPGRADE_OPPORTUNITIES.md +0 -0
  57. {shadowshield-0.6.0 → shadowshield-0.6.2}/docs/configuration.md +0 -0
  58. {shadowshield-0.6.0 → shadowshield-0.6.2}/docs/detectors.md +0 -0
  59. {shadowshield-0.6.0 → shadowshield-0.6.2}/docs/plugins.md +0 -0
  60. {shadowshield-0.6.0 → shadowshield-0.6.2}/docs/research/LANDSCAPE.md +0 -0
  61. {shadowshield-0.6.0 → shadowshield-0.6.2}/docs/security-model.md +0 -0
  62. {shadowshield-0.6.0 → shadowshield-0.6.2}/examples/agentic_security.py +0 -0
  63. {shadowshield-0.6.0 → shadowshield-0.6.2}/examples/custom_detector.py +0 -0
  64. {shadowshield-0.6.0 → shadowshield-0.6.2}/examples/langchain_integration.py +0 -0
  65. {shadowshield-0.6.0 → shadowshield-0.6.2}/examples/openai_integration.py +0 -0
  66. {shadowshield-0.6.0 → shadowshield-0.6.2}/examples/quickstart.py +0 -0
  67. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/config/__init__.py +0 -0
  68. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/config/default.yaml +0 -0
  69. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/core/__init__.py +0 -0
  70. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/core/canary.py +0 -0
  71. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/core/config.py +0 -0
  72. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/core/coverage.py +0 -0
  73. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/core/engine.py +0 -0
  74. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/core/policy.py +0 -0
  75. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/core/session.py +0 -0
  76. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/core/telemetry.py +0 -0
  77. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/core/types.py +0 -0
  78. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/detectors/__init__.py +0 -0
  79. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/detectors/alignment.py +0 -0
  80. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/detectors/anomaly.py +0 -0
  81. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/detectors/canary.py +0 -0
  82. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/detectors/data/attack_corpus.txt +0 -0
  83. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/detectors/encoding.py +0 -0
  84. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/detectors/llm_check.py +0 -0
  85. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/detectors/pii.py +0 -0
  86. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/eval/data/adversarial_benchmark.jsonl +0 -0
  87. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/eval/data/builtin_benchmark.jsonl +0 -0
  88. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/eval/harness.py +0 -0
  89. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/integrations/__init__.py +0 -0
  90. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/integrations/agentdojo.py +0 -0
  91. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/integrations/mcp.py +0 -0
  92. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/middleware/__init__.py +0 -0
  93. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/middleware/base.py +0 -0
  94. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/middleware/decorators.py +0 -0
  95. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/middleware/langchain.py +0 -0
  96. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/middleware/openai.py +0 -0
  97. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/plugins/__init__.py +0 -0
  98. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/plugins/base.py +0 -0
  99. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/plugins/manager.py +0 -0
  100. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/py.typed +0 -0
  101. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/responders/__init__.py +0 -0
  102. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/responders/base.py +0 -0
  103. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/responders/blocker.py +0 -0
  104. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/responders/isolator.py +0 -0
  105. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/responders/rate_limiter.py +0 -0
  106. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/responders/sanitizer.py +0 -0
  107. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/static/dashboard.html +0 -0
  108. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/utils/__init__.py +0 -0
  109. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/utils/logging.py +0 -0
  110. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/utils/scoring.py +0 -0
  111. {shadowshield-0.6.0 → shadowshield-0.6.2}/src/shadowshield/utils/text.py +0 -0
  112. {shadowshield-0.6.0 → shadowshield-0.6.2}/tests/__init__.py +0 -0
  113. {shadowshield-0.6.0 → shadowshield-0.6.2}/tests/test_agentic.py +0 -0
  114. {shadowshield-0.6.0 → shadowshield-0.6.2}/tests/test_config.py +0 -0
  115. {shadowshield-0.6.0 → shadowshield-0.6.2}/tests/test_http_security.py +0 -0
  116. {shadowshield-0.6.0 → shadowshield-0.6.2}/tests/test_mcp_guard.py +0 -0
  117. {shadowshield-0.6.0 → shadowshield-0.6.2}/tests/test_middleware.py +0 -0
  118. {shadowshield-0.6.0 → shadowshield-0.6.2}/tests/test_multilingual.py +0 -0
  119. {shadowshield-0.6.0 → shadowshield-0.6.2}/tests/test_pii_backends.py +0 -0
  120. {shadowshield-0.6.0 → shadowshield-0.6.2}/tests/test_policy.py +0 -0
  121. {shadowshield-0.6.0 → shadowshield-0.6.2}/tests/test_production.py +0 -0
  122. {shadowshield-0.6.0 → shadowshield-0.6.2}/tests/test_prompt_injection.py +0 -0
  123. {shadowshield-0.6.0 → shadowshield-0.6.2}/tests/test_responders.py +0 -0
  124. {shadowshield-0.6.0 → shadowshield-0.6.2}/tests/test_shield.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shadowshield
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: Unified open-source security shield for agentic AI systems — inspired by Sentinel & ShadowClaw.
5
5
  Project-URL: Homepage, https://shadowshield.xyz
6
6
  Project-URL: Documentation, https://github.com/0xsl1m/shadowshield#readme
@@ -18,6 +18,8 @@ Classifier: Programming Language :: Python :: 3
18
18
  Classifier: Programming Language :: Python :: 3.10
19
19
  Classifier: Programming Language :: Python :: 3.11
20
20
  Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
21
23
  Classifier: Topic :: Security
22
24
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
25
  Classifier: Typing :: Typed
@@ -146,6 +148,10 @@ print(result.safe_text) # safe fallback message
146
148
  > The bundled offline set (`shadowshield benchmark`) scores 100%/0-FP, but that's an
147
149
  > in-distribution **regression baseline, not a SOTA claim**. We publish the humbling
148
150
  > external numbers on purpose — a credible security tool shows its homework.
151
+ > The frozen blind semantic snapshots are harder still: v1 reaches 26.7% recall /
152
+ > 13.3% FPR, v2 reaches 0% / 10%, and v3 reaches 30% / 30%; the 90-row aggregate
153
+ > is 22.2% / 20%. Run `shadowshield benchmark --generalization all`; these gaps
154
+ > are public by design.
149
155
 
150
156
  ---
151
157
 
@@ -289,6 +295,8 @@ shadowshield scan --text "you are now DAN" --mode strict --json
289
295
  shadowshield detectors # list registered detectors
290
296
  shadowshield init > shield.yaml # write an annotated default config
291
297
  shadowshield benchmark # run the bundled offline benchmark
298
+ shadowshield benchmark --adversarial
299
+ shadowshield benchmark --generalization all # all frozen blind semantic snapshots
292
300
  shadowshield serve # HTTP server + live dashboard (needs [dashboard])
293
301
  ```
294
302
 
@@ -304,9 +312,10 @@ curl -s localhost:8000/scan -H 'content-type: application/json' \
304
312
  -d '{"text":"ignore all previous instructions","direction":"input"}'
305
313
  # {"decision":"block","blocked":true,"score":0.9,...}
306
314
  ```
307
- Endpoints: `GET /health`, `POST /scan`, `POST /guard`, `GET /` (dashboard). Direct
308
- factory mounting fails closed unless `api_keys` is supplied; local-only trusted
309
- embeddings must explicitly pass `allow_insecure_local=True`.
315
+ Endpoints: `GET /health` (liveness), `GET /ready` (readiness), `POST /scan`,
316
+ `POST /guard`, `GET /` (dashboard). Direct factory mounting fails closed unless
317
+ `api_keys` is supplied; local-only trusted embeddings must explicitly pass
318
+ `allow_insecure_local=True`.
310
319
 
311
320
  ### Production container
312
321
 
@@ -319,13 +328,46 @@ credentials, requires signed policies, and persists authenticated anti-replay st
319
328
  export SHADOWSHIELD_API_KEY="$(openssl rand -hex 32)"
320
329
  export SHADOWSHIELD_ADMIN_KEY="$(openssl rand -hex 32)"
321
330
  export SHADOWSHIELD_POLICY_KEY="$(openssl rand -hex 32)"
322
- docker compose up --build
331
+ export SHADOWSHIELD_POLICY_STATE_KEY="$(openssl rand -hex 32)"
332
+ export SHADOWSHIELD_IMAGE_DIGEST="$(curl -fsSL \
333
+ https://github.com/0xsl1m/shadowshield/releases/download/v0.6.2/container-digest.txt)"
334
+ docker compose pull
335
+ docker compose up -d
323
336
  ```
324
337
 
325
- Terminate TLS at a trusted ingress before exposing it beyond localhost. See the
338
+ The release workflow scans that exact image, publishes it to GHCR, signs SLSA
339
+ provenance plus the CycloneDX SBOM with ephemeral GitHub OIDC/Sigstore identity,
340
+ verifies the registry-attached source/workflow claims and an anonymous digest
341
+ pull, and attaches the digest plus SBOM to the matching GitHub Release. Stable
342
+ release tags are accepted only from an exact green `main` commit. All four
343
+ secrets must be independent. Terminate TLS at a trusted ingress before exposing
344
+ it beyond localhost. See the
326
345
  [production-readiness roadmap](docs/PRODUCTION_READINESS.md) for launch gates,
327
346
  known scale limits, and the operator checklist.
328
347
 
348
+ Upgrading a control-plane volume from 0.6.0 requires an offline re-key because
349
+ 0.6.0 authenticated durable state with the policy-signing key:
350
+
351
+ ```bash
352
+ # Load the existing scan/admin keys first so the new Compose file can resolve.
353
+ export SHADOWSHIELD_IMAGE_DIGEST="$(curl -fsSL \
354
+ https://github.com/0xsl1m/shadowshield/releases/download/v0.6.2/container-digest.txt)"
355
+ export SHADOWSHIELD_POLICY_KEY="<existing-0.6.0-policy-key>"
356
+ export SHADOWSHIELD_POLICY_STATE_KEY="$(openssl rand -hex 32)"
357
+ # Stop every writer and snapshot the volume before running the migration.
358
+ docker compose stop shadowshield
359
+ docker compose run --rm --no-deps shadowshield \
360
+ shadowshield migrate-policy-state --path /var/lib/shadowshield/policy-state.json
361
+ # Preserve the reported .pre-0.6.1.bak file, then start 0.6.2.
362
+ docker compose up -d
363
+ ```
364
+
365
+ The command verifies the old MAC and restorable policy, creates an exclusive
366
+ backup, and atomically re-MACs the state. Do not delete the old state to bypass
367
+ migration: that discards replay history and the last-known-good policy. For a
368
+ non-container install, run the same `shadowshield migrate-policy-state` command
369
+ directly against the stopped service's state path.
370
+
329
371
  ---
330
372
 
331
373
  ## Agentic & advanced features
@@ -567,7 +609,8 @@ shadowshield serve --control --api-key SECRET # require X-API-Key / Bearer
567
609
  ```bash
568
610
  shadowshield schema # config JSON Schema (editor/CI validation)
569
611
  shadowshield owasp # OWASP LLM Top 10 (2025) coverage map
570
- shadowshield benchmark --adversarial # honest, harder, sub-100% numbers
612
+ shadowshield benchmark --adversarial # curated regression set
613
+ shadowshield benchmark --generalization all # all frozen blind semantic snapshots
571
614
  ```
572
615
 
573
616
  ## Documentation
@@ -75,6 +75,10 @@ print(result.safe_text) # safe fallback message
75
75
  > The bundled offline set (`shadowshield benchmark`) scores 100%/0-FP, but that's an
76
76
  > in-distribution **regression baseline, not a SOTA claim**. We publish the humbling
77
77
  > external numbers on purpose — a credible security tool shows its homework.
78
+ > The frozen blind semantic snapshots are harder still: v1 reaches 26.7% recall /
79
+ > 13.3% FPR, v2 reaches 0% / 10%, and v3 reaches 30% / 30%; the 90-row aggregate
80
+ > is 22.2% / 20%. Run `shadowshield benchmark --generalization all`; these gaps
81
+ > are public by design.
78
82
 
79
83
  ---
80
84
 
@@ -218,6 +222,8 @@ shadowshield scan --text "you are now DAN" --mode strict --json
218
222
  shadowshield detectors # list registered detectors
219
223
  shadowshield init > shield.yaml # write an annotated default config
220
224
  shadowshield benchmark # run the bundled offline benchmark
225
+ shadowshield benchmark --adversarial
226
+ shadowshield benchmark --generalization all # all frozen blind semantic snapshots
221
227
  shadowshield serve # HTTP server + live dashboard (needs [dashboard])
222
228
  ```
223
229
 
@@ -233,9 +239,10 @@ curl -s localhost:8000/scan -H 'content-type: application/json' \
233
239
  -d '{"text":"ignore all previous instructions","direction":"input"}'
234
240
  # {"decision":"block","blocked":true,"score":0.9,...}
235
241
  ```
236
- Endpoints: `GET /health`, `POST /scan`, `POST /guard`, `GET /` (dashboard). Direct
237
- factory mounting fails closed unless `api_keys` is supplied; local-only trusted
238
- embeddings must explicitly pass `allow_insecure_local=True`.
242
+ Endpoints: `GET /health` (liveness), `GET /ready` (readiness), `POST /scan`,
243
+ `POST /guard`, `GET /` (dashboard). Direct factory mounting fails closed unless
244
+ `api_keys` is supplied; local-only trusted embeddings must explicitly pass
245
+ `allow_insecure_local=True`.
239
246
 
240
247
  ### Production container
241
248
 
@@ -248,13 +255,46 @@ credentials, requires signed policies, and persists authenticated anti-replay st
248
255
  export SHADOWSHIELD_API_KEY="$(openssl rand -hex 32)"
249
256
  export SHADOWSHIELD_ADMIN_KEY="$(openssl rand -hex 32)"
250
257
  export SHADOWSHIELD_POLICY_KEY="$(openssl rand -hex 32)"
251
- docker compose up --build
258
+ export SHADOWSHIELD_POLICY_STATE_KEY="$(openssl rand -hex 32)"
259
+ export SHADOWSHIELD_IMAGE_DIGEST="$(curl -fsSL \
260
+ https://github.com/0xsl1m/shadowshield/releases/download/v0.6.2/container-digest.txt)"
261
+ docker compose pull
262
+ docker compose up -d
252
263
  ```
253
264
 
254
- Terminate TLS at a trusted ingress before exposing it beyond localhost. See the
265
+ The release workflow scans that exact image, publishes it to GHCR, signs SLSA
266
+ provenance plus the CycloneDX SBOM with ephemeral GitHub OIDC/Sigstore identity,
267
+ verifies the registry-attached source/workflow claims and an anonymous digest
268
+ pull, and attaches the digest plus SBOM to the matching GitHub Release. Stable
269
+ release tags are accepted only from an exact green `main` commit. All four
270
+ secrets must be independent. Terminate TLS at a trusted ingress before exposing
271
+ it beyond localhost. See the
255
272
  [production-readiness roadmap](docs/PRODUCTION_READINESS.md) for launch gates,
256
273
  known scale limits, and the operator checklist.
257
274
 
275
+ Upgrading a control-plane volume from 0.6.0 requires an offline re-key because
276
+ 0.6.0 authenticated durable state with the policy-signing key:
277
+
278
+ ```bash
279
+ # Load the existing scan/admin keys first so the new Compose file can resolve.
280
+ export SHADOWSHIELD_IMAGE_DIGEST="$(curl -fsSL \
281
+ https://github.com/0xsl1m/shadowshield/releases/download/v0.6.2/container-digest.txt)"
282
+ export SHADOWSHIELD_POLICY_KEY="<existing-0.6.0-policy-key>"
283
+ export SHADOWSHIELD_POLICY_STATE_KEY="$(openssl rand -hex 32)"
284
+ # Stop every writer and snapshot the volume before running the migration.
285
+ docker compose stop shadowshield
286
+ docker compose run --rm --no-deps shadowshield \
287
+ shadowshield migrate-policy-state --path /var/lib/shadowshield/policy-state.json
288
+ # Preserve the reported .pre-0.6.1.bak file, then start 0.6.2.
289
+ docker compose up -d
290
+ ```
291
+
292
+ The command verifies the old MAC and restorable policy, creates an exclusive
293
+ backup, and atomically re-MACs the state. Do not delete the old state to bypass
294
+ migration: that discards replay history and the last-known-good policy. For a
295
+ non-container install, run the same `shadowshield migrate-policy-state` command
296
+ directly against the stopped service's state path.
297
+
258
298
  ---
259
299
 
260
300
  ## Agentic & advanced features
@@ -496,7 +536,8 @@ shadowshield serve --control --api-key SECRET # require X-API-Key / Bearer
496
536
  ```bash
497
537
  shadowshield schema # config JSON Schema (editor/CI validation)
498
538
  shadowshield owasp # OWASP LLM Top 10 (2025) coverage map
499
- shadowshield benchmark --adversarial # honest, harder, sub-100% numbers
539
+ shadowshield benchmark --adversarial # curated regression set
540
+ shadowshield benchmark --generalization all # all frozen blind semantic snapshots
500
541
  ```
501
542
 
502
543
  ## Documentation
@@ -10,6 +10,13 @@ one** — a guard that blocks everything has perfect recall and is useless.
10
10
  ```bash
11
11
  # Bundled offline benchmark (no network, no extra deps)
12
12
  shadowshield benchmark
13
+ shadowshield benchmark --adversarial
14
+
15
+ # Independently authored blind semantic snapshots
16
+ shadowshield benchmark --generalization v1
17
+ shadowshield benchmark --generalization v2
18
+ shadowshield benchmark --generalization v3
19
+ shadowshield benchmark --generalization all
13
20
 
14
21
  # External public dataset (needs the datasets extra)
15
22
  pip install "shadowshield[datasets]"
@@ -30,9 +37,54 @@ negatives). `balanced` mode:
30
37
 
31
38
  **This is a regression baseline and a smoke test — NOT a claim of real-world
32
39
  accuracy.** 100% on our own set just means we don't regress on the attack
33
- catalogue we curated. The number that matters is the external one below.
40
+ catalogue we curated. The blind and external numbers below are the real constraint.
41
+
42
+ ## 2. Curated adversarial regression set
43
+
44
+ The 36-row adversarial catalogue includes obfuscation, multilingual and indirect
45
+ attacks, plus benign trigger-heavy counterexamples. It improved from
46
+ 15/2/16/3 to 18/0/18/0 (TP/FP/TN/FN):
47
+
48
+ | recall | FPR | precision | F1 |
49
+ |---:|---:|---:|---:|
50
+ | 100% | 0% | 100% | 100% |
51
+
52
+ This is still a curated regression set. The signatures were developed with these
53
+ cases visible, so its perfect score is not a generalization claim.
54
+
55
+ ## 3. Blind semantic snapshots (the anti-gaming result)
56
+
57
+ Three balanced snapshots were authored independently without detector or regex
58
+ context, then frozen. They deliberately pair attacks with benign text containing
59
+ similar roleplay, authority, debug, and policy vocabulary.
60
+
61
+ Isolation protocol: the v1/v2 authoring tasks received only the five semantic
62
+ category names, required row/balance counts, and a request for attack/benign
63
+ contrast pairs. The v3 task received modality coverage requirements but no
64
+ detector source, signatures, or current predictions. The files were frozen before
65
+ detector tuning and are integrity-pinned in tests:
66
+ v1 SHA-256 `b3281ba1a42d266bb930bbb41943016d47b38dbc822ff7cff5131f3448a0248f`;
67
+ v2 SHA-256 `aa8b8c81c00a55bb65180e15ff743b6241d24845b3886e8e60b52b9b23db47fa`;
68
+ v3 SHA-256 `2285031e8143572311a522a4b6ec1a39c96a34ac2b42785f17818e8a145342bf`.
69
+
70
+ | snapshot | rows | TP/FP/TN/FN | recall | FPR | balanced accuracy |
71
+ |---|---:|---:|---:|---:|---:|
72
+ | v1 | 30 | 4/2/13/11 | 26.7% | 13.3% | 56.7% |
73
+ | v2 | 20 | 0/1/9/10 | 0% | 10% | 45% |
74
+ | v3 | 40 | 6/6/14/14 | 30% | 30% | 50% |
75
+ | **aggregate** | **90** | **10/9/36/35** | **22.2%** | **20%** | **51.1%** |
76
+
77
+ The v3 snapshot was opened only after a detector candidate and acceptance bar
78
+ were frozen. That candidate raised aggregate recall to 55.6% but failed the
79
+ predeclared false-positive and balanced-accuracy gates (24.4% FPR; 65.6%
80
+ balanced accuracy), so it was discarded in full. No v3-driven detector tuning
81
+ ships in 0.6.2; the table records the unchanged released core.
82
+
83
+ These snapshots expose the core deterministic tier's semantic-generalization
84
+ ceiling and are development evidence for a fresh-corpus classifier/conjunction
85
+ tranche, not numbers to hide or a set to tune against.
34
86
 
35
- ## 2. External: `deepset/prompt-injections` (out-of-distribution — the honest number)
87
+ ## 4. External: `deepset/prompt-injections` (out-of-distribution)
36
88
 
37
89
  The field's standard public smoke set (662 rows, English + German, diverse
38
90
  phrasings). This is where in-distribution scores collapse — and ours do too.
@@ -72,7 +124,7 @@ via `use_transformer="meta-llama/Llama-Prompt-Guard-2-22M"`. Note: the Prompt-Gu
72
124
  models are **gated** on HuggingFace — accept the license and run `huggingface-cli
73
125
  login` (or set `HF_TOKEN`) first. The default ProtectAI model needs no token.
74
126
 
75
- ## 3. Interpretation & roadmap
127
+ ## 5. Interpretation & roadmap
76
128
 
77
129
  - **Use the deterministic tiers** for cheap, explainable, obfuscation-aware,
78
130
  zero-false-positive catches and for everything the classifier can't do
@@ -90,6 +142,7 @@ it needs `pip install agentdojo` and an LLM API key; the adapter and a
90
142
  standalone `scan_messages_for_injection` helper are tested and ready. Publishing
91
143
  the ASR-at-fixed-utility number is the next milestone.
92
144
 
93
- > Numbers measured 2026-06-12 on CPU. Latency is hardware-dependent; the
145
+ > External model numbers measured 2026-06-12; curated/blind snapshots measured
146
+ > 2026-07-25 on CPU. Latency is hardware-dependent; the
94
147
  > classifier adds tens of ms/scan on CPU, the vector tier ~20 ms, vs. sub-ms for
95
148
  > the deterministic tiers.
@@ -1,6 +1,6 @@
1
1
  # Production readiness roadmap
2
2
 
3
- **Baseline:** ShadowShield 0.6.0 · **Audit date:** 2026-07-25
3
+ **Baseline:** ShadowShield 0.6.2 · **Audit date:** 2026-07-25
4
4
 
5
5
  This roadmap is the release gate for the Python library and the optional HTTP
6
6
  control plane. A checked item is implemented and locally verified; unchecked
@@ -10,12 +10,12 @@ items require an explicit operator or maintainer decision before public launch.
10
10
 
11
11
  | Area | State | Evidence / remaining action |
12
12
  |---|---|---|
13
- | Correctness | Ready | Full unit/integration suite; strict mypy; Ruff lint and format |
13
+ | Correctness | Ready | 290+ unit/integration cases; strict mypy; Ruff lint/format; CodeQL extended scanning |
14
14
  | Packaging | Ready | Isolated sdist/wheel build, Twine metadata check, installed-wheel CI smoke test |
15
15
  | Runtime security | Ready with configuration | Early API-key/Bearer auth, body limits, restricted CORS, immutable protection floor, non-root read-only container |
16
- | Supply chain | Ready | SHA-pinned Actions, PyPI OIDC publishing, and CI dependency audit |
16
+ | Supply chain | Ready | Repository-enforced Action SHA pins, PyPI OIDC, exact-green-main release gates, pre-push image vulnerability gate, anonymous GHCR digest pull, registry-verified SLSA and CycloneDX attestations |
17
17
  | Observability | Ready for single process | Content-free telemetry, Prometheus endpoint, bounded in-memory event feed |
18
- | Detection quality | Beta | Adversarial baseline: 83.3% recall, 11.1% FPR; tune in shadow mode before enforcement |
18
+ | Detection quality | Beta | Curated adversarial: 100%/0% FPR; blind v1–v3 aggregate: 22.2%/20%; v3: 30%/30%; deepset core: 23.3%/0% |
19
19
  | Scale / HA | Not yet | Counters, events, configuration, and rate limits are process-local |
20
20
  | Streaming | Not yet | Outputs must be buffered before scanning |
21
21
 
@@ -38,28 +38,58 @@ items require an explicit operator or maintainer decision before public launch.
38
38
  signed last-known-good policy state.
39
39
  - [x] Bound scan concurrency and ensure permanently hung judges cannot block shutdown.
40
40
  - [x] Pin every third-party GitHub Action to a reviewed commit SHA.
41
+ - [x] Separate policy-state authentication from policy signing and reject all
42
+ pairwise-equal production credentials.
43
+ - [x] Add a verified offline 0.6.0 policy-state migration and reject state that
44
+ would exceed the bounded restart parser before changing the live policy.
45
+ - [x] Add non-loading readiness probes and explicit fail-fast detector warmup.
46
+ - [x] Make Transformer loading and Vector mutation single-flight/transactional.
47
+ - [x] Bound Reporter flush/retry/close lifecycle under concurrency.
48
+ - [x] Pin the base image by digest, gate the exact release image on Trivy, publish
49
+ it to GHCR, verify an anonymous digest pull, and attach its immutable digest
50
+ plus CycloneDX SBOM to the release.
51
+ - [x] Sign release-image SLSA provenance and the CycloneDX SBOM with ephemeral
52
+ GitHub OIDC/Sigstore identity, then verify repository, workflow, runner, and
53
+ source-commit claims from the OCI registry before completing the release.
54
+ - [x] Reject release publication unless a stable, non-prerelease tag matches the
55
+ package version, resolves to `main`, and has a successful exact-SHA `main` CI run.
56
+ - [x] Reject symlink/non-regular durable policy-state files, use unpredictable
57
+ exclusive temporary files, and revision-check reads, migrations, and replaces.
58
+ - [x] Coalesce concurrent failed Transformer/Vector load generations while giving
59
+ every waiter a fresh bounded exception traceback and allowing a later retry.
60
+ - [x] Enable Dependabot security updates, extended CodeQL scanning, secret
61
+ push-protection, and repository-level enforcement of full-SHA Action pins.
62
+ - [x] Publish a third independently authored blind snapshot and reject—not
63
+ tune—the first frozen candidate when it missed the predeclared FPR/accuracy bar.
41
64
 
42
65
  ## P1 — launch procedure (operator-owned)
43
66
 
44
- - [ ] Set independent high-entropy `SHADOWSHIELD_API_KEY`, `SHADOWSHIELD_ADMIN_KEY`, and `SHADOWSHIELD_POLICY_KEY` values; terminate TLS at a trusted ingress.
67
+ - [ ] Set independent high-entropy `SHADOWSHIELD_API_KEY`,
68
+ `SHADOWSHIELD_ADMIN_KEY`, `SHADOWSHIELD_POLICY_KEY`, and
69
+ `SHADOWSHIELD_POLICY_STATE_KEY` values; terminate TLS at a trusted ingress.
70
+ - [ ] When upgrading a 0.6.0 durable volume, stop all writers, snapshot the
71
+ volume, run `shadowshield migrate-policy-state`, retain its verified backup,
72
+ and confirm the restored policy/version before accepting traffic.
45
73
  - [ ] Set an explicit CORS allowlist or leave CORS disabled.
46
74
  - [ ] Run the adversarial and application-specific eval sets in permissive/shadow mode.
47
75
  - [ ] Choose thresholds from measured false-positive cost; record the accepted baseline.
48
76
  - [ ] Connect Prometheus and content-free telemetry; alert on error, block-rate, and latency shifts.
49
77
  - [ ] Load-test representative payload sizes and concurrency on the target instance class.
50
- - [ ] Pin the image by digest, scan it, generate/store an SBOM, and rehearse rollback.
78
+ - [ ] Record the deployed image digest and CI SBOM, then rehearse backup/restore
79
+ and rollback against the target environment.
51
80
  - [ ] Protect the PyPI GitHub Environment with required reviewers before publishing.
52
81
 
53
82
  ## P2 — next engineering milestones
54
83
 
55
84
  1. Add a rolling-window streaming scanner that blocks before unsafe output is emitted.
56
85
  2. Move rate limits, event history, and metrics aggregation to shared external stores for multi-worker HA.
57
- 3. Add readiness/startup probes that validate optional model assets when ML detectors are enabled.
58
- 4. Publish signed container images, provenance attestations, and an SBOM from the release workflow.
59
- 5. Calibrate detector scores on a larger independently sourced corpus and publish confidence intervals.
60
- 6. Add soak, concurrency, and fault-injection tests for reporters, judges, and hot policy updates.
61
- 7. Split the current administrator credential into narrower observe and mutation scopes.
62
- 8. Add single-flight model loading and synchronized vector-index mutation.
86
+ 3. Build a fresh independently sourced development corpus, then evaluate the
87
+ next detector candidate once on a new sealed snapshot and publish confidence intervals.
88
+ 4. Add multi-hour soak and network fault-injection tests for reporters, judges, and hot policy updates.
89
+ 5. Split the current administrator credential into narrower observe and mutation scopes.
90
+ 6. Design cross-process model/index coordination and durable Reporter spooling.
91
+ 7. Split unprivileged container build/scan from the write/OIDC publication job.
92
+ 8. Refactor release evidence into a draft-first pipeline, then enable immutable releases.
63
93
 
64
94
  ## Go / no-go gates
65
95
 
@@ -0,0 +1,109 @@
1
+ # Releasing ShadowShield
2
+
3
+ ShadowShield publishes via **PyPI Trusted Publishing** (OpenID Connect from GitHub
4
+ Actions). No API token is ever created, stored, or pasted anywhere — PyPI trusts
5
+ this specific GitHub workflow directly. The pipeline is
6
+ [`publish.yml`](../.github/workflows/publish.yml).
7
+
8
+ The same GitHub Release also triggers
9
+ [`container-release.yml`](../.github/workflows/container-release.yml). It builds
10
+ one image from the release tag, generates a CycloneDX SBOM, rejects fixable
11
+ high/critical findings, pushes that exact scanned image to GHCR, signs SLSA
12
+ provenance and CycloneDX attestations through GitHub's Sigstore-backed OIDC
13
+ service, verifies the signed provenance and an anonymous pull by digest, and
14
+ attaches `container-digest.txt` plus the SBOM to the GitHub Release.
15
+
16
+ ## One-time setup (PyPI account owner only)
17
+
18
+ This must be done in the PyPI web UI by the account that will own the project —
19
+ it cannot be automated.
20
+
21
+ 1. Create a PyPI account (and enable 2FA): <https://pypi.org/account/register/>.
22
+ 2. Go to **<https://pypi.org/manage/account/publishing/>** → "Add a new pending
23
+ publisher" and enter **exactly**:
24
+ - **PyPI Project Name:** `shadowshield`
25
+ - **Owner:** `0xsl1m`
26
+ - **Repository name:** `shadowshield`
27
+ - **Workflow name:** `publish.yml`
28
+ - **Environment name:** *(leave blank — the workflow declares no environment)*
29
+ 3. Save. This registers a *pending publisher*; the PyPI project is created
30
+ automatically on the first successful publish.
31
+
32
+ > Optional hardening: create a GitHub Environment named `pypi` with required
33
+ > reviewers, add `environment: pypi` to the `publish` job, and set the same
34
+ > environment name in the PyPI publisher config. Then every publish needs manual
35
+ > approval in GitHub.
36
+
37
+ ## One-time setup (GHCR package owner only)
38
+
39
+ GitHub may create a first-published container package as private. The release
40
+ workflow deliberately fails its anonymous-pull gate if that happens.
41
+
42
+ 1. Let the first `Publish release container` run push the package.
43
+ 2. If the anonymous-pull step fails, open the `shadowshield` package settings
44
+ under the `0xsl1m` account and change visibility to **Public**.
45
+ 3. Re-run the failed workflow job. Do not publish the release digest to operators
46
+ until the anonymous pull and evidence-attachment steps are green.
47
+
48
+ ## Cutting a release
49
+
50
+ Only release an exact commit whose PR and `main` CI checks are green:
51
+
52
+ ```bash
53
+ # 1. bump the version in pyproject.toml + src/shadowshield/__init__.py, update CHANGELOG
54
+ # 2. merge through a reviewed PR and wait for main CI + site deployment
55
+ # 3. create the release/tag at that exact merge commit
56
+ merge_sha="$(git rev-parse origin/main)"
57
+ gh release create vX.Y.Z --target "$merge_sha" \
58
+ --title "ShadowShield X.Y.Z" --notes "..."
59
+ # -> PyPI publishing and exact-image scan/publish run independently
60
+ ```
61
+
62
+ Do not create the release while required checks are pending. A partial release
63
+ is recoverable by fixing the failed workflow and re-running it, but the PyPI
64
+ version itself cannot be replaced.
65
+
66
+ Both publication workflows independently reject non-stable tags, tag/source
67
+ version mismatches, commits outside `main`, and commits without a successful
68
+ exact-SHA `main` CI run. There is no manual-dispatch publication path; use
69
+ GitHub's **Re-run failed jobs** control to recover a failed release workflow.
70
+
71
+ ## Verify
72
+
73
+ ```bash
74
+ pip index versions shadowshield # should list the new version
75
+ pip install shadowshield==X.Y.Z # clean-env smoke test
76
+ python -c "import shadowshield as ss; print(ss.__version__)"
77
+
78
+ gh release download vX.Y.Z \
79
+ --pattern container-digest.txt --pattern shadowshield-sbom.cdx.json
80
+ digest="$(cat container-digest.txt)"
81
+ image="ghcr.io/0xsl1m/shadowshield@$digest"
82
+ docker pull "$image"
83
+ gh attestation verify "oci://$image" \
84
+ --repo 0xsl1m/shadowshield \
85
+ --bundle-from-oci \
86
+ --signer-workflow 0xsl1m/shadowshield/.github/workflows/container-release.yml \
87
+ --source-digest "$(git rev-list -n 1 vX.Y.Z)" \
88
+ --deny-self-hosted-runners
89
+ gh attestation verify "oci://$image" \
90
+ --repo 0xsl1m/shadowshield \
91
+ --bundle-from-oci \
92
+ --predicate-type https://cyclonedx.org/bom \
93
+ --signer-workflow 0xsl1m/shadowshield/.github/workflows/container-release.yml \
94
+ --source-digest "$(git rev-list -n 1 vX.Y.Z)" \
95
+ --deny-self-hosted-runners
96
+ ```
97
+
98
+ ## Notes
99
+
100
+ - **Versions are immutable.** A published version can never be re-uploaded, only
101
+ *yanked*. Always confirm `twine check` is green (CI does this) before releasing.
102
+ - Deploy the container by the exact `image@sha256` value in
103
+ `container-digest.txt`, never by a mutable version tag or a local Compose
104
+ rebuild. The release SBOM describes that exact pre-push-scanned image.
105
+ - The default install stays lightweight; the ML/vector/PII/dataset stacks are
106
+ optional extras (see `pyproject.toml`).
107
+ - Want to rehearse first? Configure a second pending publisher on
108
+ <https://test.pypi.org> and add a TestPyPI step — but the production path above
109
+ is already `twine check`-validated.
@@ -8,7 +8,7 @@ systems. This directory is the deep-dive companion to the top-level
8
8
 
9
9
  - [Benchmarks](BENCHMARKS.md) — measured detection/FPR on the bundled set **and** the public `deepset` set (regex-only vs. classifier).
10
10
  - [Comparison](COMPARISON.md) — competitive audit + capability matrix vs. LLM Guard, LlamaFirewall, NeMo, Guardrails, Rebuff.
11
- - [Releasing](RELEASING.md) — how ShadowShield ships to PyPI (Trusted Publishing, no tokens).
11
+ - [Releasing](RELEASING.md) — PyPI Trusted Publishing plus exact scanned GHCR images and SBOMs.
12
12
  - [Production readiness](PRODUCTION_READINESS.md) — release gates, deployment
13
13
  checklist, known scale limits, and the next engineering milestones.
14
14
  - [Security model](security-model.md) — threat taxonomy, layers, trust boundaries.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "shadowshield"
7
- version = "0.6.0"
7
+ version = "0.6.2"
8
8
  description = "Unified open-source security shield for agentic AI systems — inspired by Sentinel & ShadowClaw."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -30,6 +30,8 @@ classifiers = [
30
30
  "Programming Language :: Python :: 3.10",
31
31
  "Programming Language :: Python :: 3.11",
32
32
  "Programming Language :: Python :: 3.12",
33
+ "Programming Language :: Python :: 3.13",
34
+ "Programming Language :: Python :: 3.14",
33
35
  "Topic :: Security",
34
36
  "Topic :: Software Development :: Libraries :: Python Modules",
35
37
  "Typing :: Typed",
@@ -71,7 +71,7 @@ from .plugins import PluginManager, ShadowShieldPlugin
71
71
  from .reporter import Reporter, attach_reporter
72
72
  from .responders import Responder, spotlight
73
73
 
74
- __version__ = "0.6.0"
74
+ __version__ = "0.6.2"
75
75
 
76
76
  __all__ = [
77
77
  "__version__",
@@ -15,6 +15,7 @@ Configuration precedence: explicit arguments first, then environment:
15
15
  - ``SHADOWSHIELD_ADMIN_KEY`` - comma-separated control/metrics administrator keys.
16
16
  - ``SHADOWSHIELD_CORS_ORIGINS`` - comma-separated list of allowed origins.
17
17
  - ``SHADOWSHIELD_POLICY_KEY`` - HMAC key required for remotely exposed policy updates.
18
+ - ``SHADOWSHIELD_POLICY_STATE_KEY`` - independent HMAC key for durable policy state.
18
19
  - ``SHADOWSHIELD_POLICY_STATE_PATH`` - durable anti-replay state file.
19
20
 
20
21
  Keys are compared with :func:`hmac.compare_digest` (constant-time) at request time.
@@ -26,6 +27,7 @@ import asyncio
26
27
  import hmac
27
28
  import json
28
29
  import os
30
+ from collections.abc import Sequence
29
31
  from typing import Any
30
32
 
31
33
  MAX_HTTP_BODY_BYTES = 1_048_576
@@ -75,11 +77,40 @@ def resolve_policy_key(explicit: bytes | str | None = None) -> bytes | None:
75
77
  return value
76
78
 
77
79
 
80
+ def resolve_policy_state_key(explicit: bytes | str | None = None) -> bytes | None:
81
+ """Policy-state HMAC key from args or ``SHADOWSHIELD_POLICY_STATE_KEY``."""
82
+ value: bytes | str | None = explicit or os.environ.get("SHADOWSHIELD_POLICY_STATE_KEY")
83
+ if isinstance(value, str):
84
+ return value.encode("utf-8") if value else None
85
+ return value
86
+
87
+
78
88
  def resolve_policy_state_path(explicit: str | None = None) -> str | None:
79
89
  """Durable anti-replay state path from args or the deployment environment."""
80
90
  return explicit or os.environ.get("SHADOWSHIELD_POLICY_STATE_PATH") or None
81
91
 
82
92
 
93
+ def secret_groups_overlap(
94
+ left: Sequence[bytes | str],
95
+ right: Sequence[bytes | str],
96
+ ) -> bool:
97
+ """Return whether two credential groups share a value.
98
+
99
+ Every candidate comparison uses :func:`hmac.compare_digest`; the loops do
100
+ not short-circuit when a match is found.
101
+ """
102
+
103
+ overlap = False
104
+ for left_value in left:
105
+ left_bytes = left_value.encode("utf-8") if isinstance(left_value, str) else left_value
106
+ for right_value in right:
107
+ right_bytes = (
108
+ right_value.encode("utf-8") if isinstance(right_value, str) else right_value
109
+ )
110
+ overlap |= hmac.compare_digest(left_bytes, right_bytes)
111
+ return overlap
112
+
113
+
83
114
  def extract_key(x_api_key: str | None, authorization: str | None) -> str | None:
84
115
  """Pull a presented key from either the ``X-API-Key`` or ``Bearer`` header."""
85
116
  if x_api_key: