llm-redact-proxy 1.0.0__py3-none-any.whl

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 (79) hide show
  1. llm_redact/__init__.py +3 -0
  2. llm_redact/__main__.py +4 -0
  3. llm_redact/audit.py +135 -0
  4. llm_redact/audit_s3.py +97 -0
  5. llm_redact/bench/__init__.py +6 -0
  6. llm_redact/bench/__main__.py +110 -0
  7. llm_redact/bench/corpus.py +968 -0
  8. llm_redact/bench/fp_scan.py +129 -0
  9. llm_redact/bench/latency.py +365 -0
  10. llm_redact/bench/metrics.py +124 -0
  11. llm_redact/cli.py +753 -0
  12. llm_redact/cloud_detect.py +92 -0
  13. llm_redact/completions.py +117 -0
  14. llm_redact/config.py +1023 -0
  15. llm_redact/config_write.py +314 -0
  16. llm_redact/dashboard.html +1218 -0
  17. llm_redact/detection/__init__.py +4 -0
  18. llm_redact/detection/base.py +24 -0
  19. llm_redact/detection/deny.py +57 -0
  20. llm_redact/detection/engine.py +345 -0
  21. llm_redact/detection/gliner_ner.py +73 -0
  22. llm_redact/detection/hf_ner.py +85 -0
  23. llm_redact/detection/ner.py +76 -0
  24. llm_redact/detection/presidio_ner.py +123 -0
  25. llm_redact/detection/regex_rules.py +1647 -0
  26. llm_redact/detection/stanza_ner.py +76 -0
  27. llm_redact/detection/validators.py +114 -0
  28. llm_redact/detection/wallet_checksums.py +264 -0
  29. llm_redact/doctor_cli.py +687 -0
  30. llm_redact/eventstream.py +216 -0
  31. llm_redact/fips.py +77 -0
  32. llm_redact/free_defaults.py +80 -0
  33. llm_redact/init_cli.py +147 -0
  34. llm_redact/jsonwalk.py +82 -0
  35. llm_redact/license_cli.py +94 -0
  36. llm_redact/licensing.py +133 -0
  37. llm_redact/log.py +47 -0
  38. llm_redact/metrics.py +154 -0
  39. llm_redact/multipart.py +116 -0
  40. llm_redact/ndjson.py +25 -0
  41. llm_redact/placeholders.py +96 -0
  42. llm_redact/plugin_api.py +92 -0
  43. llm_redact/plugin_assets.py +398 -0
  44. llm_redact/plugin_cli.py +298 -0
  45. llm_redact/providers/__init__.py +46 -0
  46. llm_redact/providers/anthropic.py +238 -0
  47. llm_redact/providers/azure_openai.py +83 -0
  48. llm_redact/providers/base.py +290 -0
  49. llm_redact/providers/bedrock.py +269 -0
  50. llm_redact/providers/claude_vertex.py +52 -0
  51. llm_redact/providers/cohere.py +204 -0
  52. llm_redact/providers/custom.py +105 -0
  53. llm_redact/providers/gemini.py +255 -0
  54. llm_redact/providers/ollama.py +129 -0
  55. llm_redact/providers/openai.py +371 -0
  56. llm_redact/providers/openai_responses.py +271 -0
  57. llm_redact/providers/vertex.py +50 -0
  58. llm_redact/proxy.py +2398 -0
  59. llm_redact/py.typed +0 -0
  60. llm_redact/realtime.py +861 -0
  61. llm_redact/redactor.py +124 -0
  62. llm_redact/registry.py +142 -0
  63. llm_redact/rehydrate.py +270 -0
  64. llm_redact/run_cli.py +215 -0
  65. llm_redact/service_cli.py +199 -0
  66. llm_redact/sessions.py +72 -0
  67. llm_redact/sse.py +100 -0
  68. llm_redact/user_guide.html +73 -0
  69. llm_redact/user_guide.md +124 -0
  70. llm_redact/users.py +162 -0
  71. llm_redact/vault.py +715 -0
  72. llm_redact/vault_cli.py +673 -0
  73. llm_redact/vault_crypto.py +104 -0
  74. llm_redact/vault_rdbms.py +891 -0
  75. llm_redact_proxy-1.0.0.dist-info/METADATA +526 -0
  76. llm_redact_proxy-1.0.0.dist-info/RECORD +79 -0
  77. llm_redact_proxy-1.0.0.dist-info/WHEEL +4 -0
  78. llm_redact_proxy-1.0.0.dist-info/entry_points.txt +2 -0
  79. llm_redact_proxy-1.0.0.dist-info/licenses/LICENSE +661 -0
llm_redact/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """llm-redact: transparent redaction/rehydration proxy for LLM API traffic."""
2
+
3
+ __version__ = "1.0.0"
llm_redact/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from llm_redact.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
llm_redact/audit.py ADDED
@@ -0,0 +1,135 @@
1
+ """Audit-log contract (the Free side of the open-core split).
2
+
3
+ The audit log records metadata only — detector types and counts, paths,
4
+ timestamps, durations. Never values, never placeholder ids, never headers or
5
+ bodies. The concrete SQLite log, its tamper-evident hash chain, and the
6
+ off-machine sinks are a paid subsystem (``llm_redact_pro.audit`` /
7
+ ``llm_redact_pro.audit_s3``). This module holds only what the Free core needs
8
+ at the seam:
9
+
10
+ - ``AuditRecord`` — the metadata row ``record_request`` builds and hands to the
11
+ log (kept here so ``proxy.py`` is unchanged),
12
+ - the ``AuditLog`` **Protocol** the proxy calls,
13
+ - the env-var name + key-resolution helper ``doctor`` uses for its posture
14
+ checks (generic env-reading glue, no paid secrecy value), and
15
+ - the fail-closed ``build_audit`` default.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import os
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
25
+
26
+ from .config import ConfigError
27
+
28
+ if TYPE_CHECKING:
29
+ from .config import AuditConfig
30
+
31
+ AUDIT_HMAC_ENV = "LLM_REDACT_AUDIT_HMAC_KEY"
32
+
33
+ _PRO_HINT = "install the llm-redact-pro package to enable it"
34
+
35
+
36
+ @dataclass
37
+ class AuditRecord:
38
+ ts: str
39
+ session: str
40
+ provider: str | None
41
+ method: str
42
+ path: str
43
+ status: int | None
44
+ duration_ms: float
45
+ streamed: bool
46
+ detections: dict[str, int] = field(default_factory=dict)
47
+ rehydrations: dict[str, int] = field(default_factory=dict)
48
+ # Named-user attribution (2.0): the user NAME only, never keys. None on
49
+ # single-user deployments and rows recorded before 2.0.
50
+ user: str | None = None
51
+ # Warn-mode hits attributed to this request (3.3): types+counts of values
52
+ # FORWARDED upstream unredacted. None when nothing warned and on rows
53
+ # recorded before the column existed.
54
+ warned: dict[str, int] | None = None
55
+
56
+
57
+ class AuditWriteError(RuntimeError):
58
+ """A required-mode audit row could not be durably committed.
59
+
60
+ Raised by write-ahead audit implementations (``[audit] required = true``,
61
+ llm-redact-pro) from ``begin``/``finalize``/``record`` when the row cannot
62
+ be committed. On the ``begin`` path the proxy answers a provider-shaped
63
+ 503 WITHOUT contacting the upstream — no durably committed audit row, no
64
+ upstream contact; after a response has been committed the proxy can only
65
+ log the failure loudly. Never raised in the default fail-open mode, where
66
+ the concrete log swallows write faults by design.
67
+ """
68
+
69
+
70
+ class AuditLog(Protocol):
71
+ """The audit-log surface the proxy depends on.
72
+
73
+ The concrete SQLite implementation (with the tamper-evident chain) is
74
+ ``llm_redact_pro.audit.AuditLog``; the Free core holds only this structural
75
+ contract. ``verify()`` — used by the offline ``audit verify`` CLI, not the
76
+ request path — lives on the concrete class only. The write-ahead pair for
77
+ ``[audit] required`` mode is the :class:`WriteAheadAudit` sub-Protocol —
78
+ kept out of this base so a pro package predating it still satisfies every
79
+ fail-open configuration, type contract included.
80
+ """
81
+
82
+ def record(self, entry: AuditRecord) -> None: ...
83
+
84
+ def recent(self, limit: int) -> list[dict[str, object]]: ...
85
+
86
+ def count(self) -> int: ...
87
+
88
+ def close(self) -> None: ...
89
+
90
+
91
+ @runtime_checkable
92
+ class WriteAheadAudit(AuditLog, Protocol):
93
+ """An audit log with the ``[audit] required`` write-ahead pair.
94
+
95
+ ``begin`` durably commits a START row BEFORE any upstream contact
96
+ (raising :class:`AuditWriteError` on failure) and returns an opaque
97
+ non-None token; ``finalize`` commits the matching END row at completion.
98
+ ``ProxyState`` resolves the capability once at startup (``isinstance``
99
+ checks method presence, the runtime-checkable contract) and refuses
100
+ required mode when the built log lacks the pair.
101
+ """
102
+
103
+ def begin(self, entry: AuditRecord) -> object: ...
104
+
105
+ def finalize(self, token: object, entry: AuditRecord) -> None: ...
106
+
107
+
108
+ def default_audit_path() -> Path:
109
+ xdg = os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local" / "share"))
110
+ return Path(xdg) / "llm-redact" / "audit.db"
111
+
112
+
113
+ def audit_hmac_key_from_env() -> bytes | None:
114
+ """The tamper-evidence HMAC key, derived from LLM_REDACT_AUDIT_HMAC_KEY.
115
+
116
+ Env-only (never the config file, matching the vault key and S3
117
+ credentials). Any-length passphrase is hashed to 32 bytes. Returns None
118
+ when unset so the caller can fail closed with a clear message.
119
+ """
120
+ raw = os.environ.get(AUDIT_HMAC_ENV, "")
121
+ if not raw:
122
+ return None
123
+ return hashlib.sha256(raw.encode("utf-8")).digest()
124
+
125
+
126
+ def build_audit(config: AuditConfig) -> AuditLog | None:
127
+ """Fail-closed Free default for the audit log.
128
+
129
+ The audit log — and its tamper-evident chain — is a paid subsystem whose
130
+ implementation lives in llm-redact-pro. Disabled is None; enabled without
131
+ the pro package fails closed rather than silently dropping the audit trail.
132
+ """
133
+ if not config.enabled:
134
+ return None
135
+ raise ConfigError(f"[audit] enabled = true requires an audit log; {_PRO_HINT}")
llm_redact/audit_s3.py ADDED
@@ -0,0 +1,97 @@
1
+ """Off-machine audit-sink contract (the Free side of the open-core split).
2
+
3
+ The concrete S3/GCS and Azure Blob sinks — the hand-rolled SigV4 and Azure
4
+ SharedKey signers, batch buffering, and client-side Fernet encryption — are a
5
+ paid (Pro-tier) subsystem in ``llm_redact_pro.audit_s3``. This module holds
6
+ only the seam:
7
+
8
+ - the ``S3AuditSink`` / ``AzureAuditSink`` Protocols the proxy runs and reads
9
+ counters from,
10
+ - the env-var names + key/credential helpers ``doctor`` uses for its posture
11
+ checks (generic env-reading glue, no paid secrecy value; credentials NEVER
12
+ come from the config file), and
13
+ - the fail-closed ``build_audit_sinks`` default.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import hashlib
20
+ import os
21
+ from typing import TYPE_CHECKING, Any, Protocol
22
+
23
+ from .config import ConfigError
24
+
25
+ if TYPE_CHECKING:
26
+ from .config import AuditConfig
27
+
28
+ _PRO_HINT = "install the llm-redact-pro package to enable it"
29
+
30
+ # Client-side batch encryption ([audit.s3]/[audit.azure] encryption = "fernet").
31
+ AUDIT_ENC_KEY_ENV = "LLM_REDACT_AUDIT_ENC_KEY"
32
+ AZURE_STORAGE_KEY_ENV = "AZURE_STORAGE_KEY"
33
+
34
+
35
+ def audit_enc_key_from_env() -> bytes | None:
36
+ """SHA-256 of the env passphrase, urlsafe-base64 — a valid Fernet key
37
+ (the audit_hmac_key_from_env recipe, shaped for Fernet)."""
38
+ raw = os.environ.get(AUDIT_ENC_KEY_ENV, "")
39
+ if not raw:
40
+ return None
41
+ return base64.urlsafe_b64encode(hashlib.sha256(raw.encode("utf-8")).digest())
42
+
43
+
44
+ # Per-provider credential environment variables. GCS is reached through its
45
+ # S3-compatible XML API (interoperability) with HMAC interoperability keys read
46
+ # from GCS-specific vars. Credentials NEVER come from the config file.
47
+ _CREDENTIAL_ENV: dict[str, tuple[str, str, str | None]] = {
48
+ "aws": ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN"),
49
+ "minio": ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN"),
50
+ "ceph": ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN"),
51
+ "gcs": ("GCS_HMAC_ACCESS_ID", "GCS_HMAC_SECRET", None),
52
+ }
53
+
54
+
55
+ def credential_env_names(provider: str) -> tuple[str, str, str | None]:
56
+ """The (access, secret, session-token) env var names for a provider."""
57
+ return _CREDENTIAL_ENV.get(provider, _CREDENTIAL_ENV["aws"])
58
+
59
+
60
+ class S3AuditSink(Protocol):
61
+ """The S3/GCS audit-sink surface the proxy runs (concrete impl in pro)."""
62
+
63
+ batches_uploaded: int
64
+ rows_dropped: int
65
+
66
+ def add(self, row: dict[str, Any]) -> None: ...
67
+
68
+ async def run(self) -> None: ...
69
+
70
+ async def aclose(self) -> None: ...
71
+
72
+
73
+ class AzureAuditSink(Protocol):
74
+ """The Azure Blob audit-sink surface the proxy runs (concrete impl in pro)."""
75
+
76
+ batches_uploaded: int
77
+ rows_dropped: int
78
+
79
+ def add(self, row: dict[str, Any]) -> None: ...
80
+
81
+ async def run(self) -> None: ...
82
+
83
+ async def aclose(self) -> None: ...
84
+
85
+
86
+ def build_audit_sinks(
87
+ config: AuditConfig,
88
+ ) -> tuple[S3AuditSink | None, AzureAuditSink | None]:
89
+ """Fail-closed Free default for the off-machine audit sinks.
90
+
91
+ Both sinks disabled is ``(None, None)``. Enabling either without the pro
92
+ package fails closed rather than silently dropping every batch — the sinks,
93
+ signers, and batch encryption are a paid subsystem.
94
+ """
95
+ if not config.s3.enabled and not config.azure.enabled:
96
+ return None, None
97
+ raise ConfigError(f"audit backup sinks require an off-machine sink; {_PRO_HINT}")
@@ -0,0 +1,6 @@
1
+ """Detection quality benchmark: deterministic synthetic corpus + metrics.
2
+
3
+ Run with ``python -m llm_redact.bench``. The corpus is generated at runtime
4
+ (never committed — no realistic-looking secrets in the repository) from a
5
+ fixed seed, so results are reproducible and rule regressions are diffable.
6
+ """
@@ -0,0 +1,110 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from llm_redact.bench.corpus import generate
7
+ from llm_redact.bench.fp_scan import fp_failures, scan_fp_corpus, to_markdown_table
8
+ from llm_redact.bench.latency import (
9
+ ceiling_failures,
10
+ run_latency,
11
+ to_json_list,
12
+ )
13
+ from llm_redact.bench.latency import (
14
+ to_markdown as latency_to_markdown,
15
+ )
16
+ from llm_redact.bench.metrics import evaluate, to_json_dict, to_markdown
17
+
18
+ DEFAULT_FP_CORPUS = Path("bench/fp_corpus")
19
+
20
+
21
+ def main(argv: list[str] | None = None) -> int:
22
+ parser = argparse.ArgumentParser(prog="python -m llm_redact.bench")
23
+ parser.add_argument("--seed", type=int, default=42)
24
+ parser.add_argument("--samples", type=int, default=25, help="positives per rule")
25
+ parser.add_argument("--out", type=Path, default=None, help="write report files here")
26
+ parser.add_argument(
27
+ "--fp-corpus",
28
+ type=Path,
29
+ default=None,
30
+ help=f"false-positive corpus root (default: {DEFAULT_FP_CORPUS} when present)",
31
+ )
32
+ parser.add_argument(
33
+ "--latency",
34
+ action="store_true",
35
+ help="also run the in-process latency benchmark (adds ~1 minute)",
36
+ )
37
+ parser.add_argument(
38
+ "--check",
39
+ action="store_true",
40
+ help="exit 1 if any rule misses its own generated positives (recall < 1.0),"
41
+ " the fp corpus deviates from its manifest, or (with --latency) a p50"
42
+ " smoke ceiling is crossed",
43
+ )
44
+ args = parser.parse_args(argv)
45
+
46
+ corpus = generate(seed=args.seed, samples_per_rule=args.samples)
47
+ result = evaluate(corpus)
48
+ markdown = to_markdown(result, seed=args.seed)
49
+
50
+ fp_root = args.fp_corpus
51
+ if fp_root is None and DEFAULT_FP_CORPUS.is_dir():
52
+ fp_root = DEFAULT_FP_CORPUS
53
+ fp_results = None
54
+ if fp_root is not None:
55
+ fp_results = scan_fp_corpus(fp_root)
56
+ markdown += (
57
+ "\n## False-positive corpus\n\n"
58
+ f"Vendored negatives at `{fp_root}`, gated on exact per-file counts.\n\n"
59
+ + to_markdown_table(fp_results)
60
+ + "\n"
61
+ )
62
+
63
+ latency_stats = None
64
+ if args.latency:
65
+ latency_stats = run_latency(seed=args.seed)
66
+ markdown += "\n" + latency_to_markdown(latency_stats)
67
+
68
+ if args.out is not None:
69
+ args.out.mkdir(parents=True, exist_ok=True)
70
+ (args.out / "report.md").write_text(markdown)
71
+ report = to_json_dict(result, seed=args.seed)
72
+ if latency_stats is not None:
73
+ report["latency"] = to_json_list(latency_stats)
74
+ (args.out / "report.json").write_text(json.dumps(report, indent=2))
75
+ print(f"report written to {args.out}/report.md and report.json")
76
+ else:
77
+ print(markdown)
78
+
79
+ if args.check:
80
+ failed = False
81
+ # Functional regression gate: the corpus is generated to match each
82
+ # rule, so a missed positive is a code regression, not corpus noise.
83
+ misses = {name: s for name, s in result.per_type.items() if s.recall < 1.0}
84
+ for name, score in misses.items():
85
+ print(f"CHECK FAILED: {name} recall {score.recall:.3f} ({score.fn} missed)")
86
+ failed = True
87
+ # Precision gate: the vendored negatives must match their manifest
88
+ # exactly, in both directions.
89
+ if fp_results is not None:
90
+ for line in fp_failures(fp_results):
91
+ print(f"CHECK FAILED (fp corpus): {line}")
92
+ failed = True
93
+ # Latency smoke ceilings: generous p50-only bounds against
94
+ # accidental quadratic behavior, not perf tuning.
95
+ if latency_stats is not None:
96
+ for line in ceiling_failures(latency_stats):
97
+ print(f"CHECK FAILED (latency): {line}")
98
+ failed = True
99
+ if failed:
100
+ return 1
101
+ fp_note = f", fp corpus clean ({len(fp_results)} files)" if fp_results is not None else ""
102
+ latency_note = ", latency ceilings ok" if latency_stats is not None else ""
103
+ print(
104
+ f"check passed: recall 1.0 on all {len(result.per_type)} types{fp_note}{latency_note}"
105
+ )
106
+ return 0
107
+
108
+
109
+ if __name__ == "__main__":
110
+ sys.exit(main())