phi-boundary-gate 0.5.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.
@@ -0,0 +1,49 @@
1
+ """PHI boundary gating, redaction, and audit reporting."""
2
+
3
+ __version__ = "0.5.0"
4
+
5
+ from .api import GuardDecision, GuardMode, ScanFinding, guard_text, redact_text, scan_text
6
+ from .compliance import (
7
+ ComplianceContext,
8
+ ComplianceDecision,
9
+ CompliancePolicy,
10
+ ServiceProfile,
11
+ guard_compliance,
12
+ load_compliance_policy,
13
+ )
14
+ from .policy import load_policy
15
+ from .project import ProjectConfig, check_project_config, discover_project_config, init_project, load_project_config
16
+ from .redacted_trace import redacted_trace_events, write_redacted_trace
17
+ from .report import build_report, render_markdown, write_json_report, write_markdown_report
18
+ from .sdk import PhiBoundaryGate
19
+ from .trace import TraceEvent, load_trace
20
+
21
+ __all__ = [
22
+ "ComplianceContext",
23
+ "ComplianceDecision",
24
+ "CompliancePolicy",
25
+ "GuardDecision",
26
+ "GuardMode",
27
+ "PhiBoundaryGate",
28
+ "ProjectConfig",
29
+ "ScanFinding",
30
+ "ServiceProfile",
31
+ "TraceEvent",
32
+ "build_report",
33
+ "check_project_config",
34
+ "discover_project_config",
35
+ "guard_compliance",
36
+ "guard_text",
37
+ "init_project",
38
+ "load_compliance_policy",
39
+ "load_policy",
40
+ "load_project_config",
41
+ "load_trace",
42
+ "redact_text",
43
+ "redacted_trace_events",
44
+ "render_markdown",
45
+ "scan_text",
46
+ "write_json_report",
47
+ "write_markdown_report",
48
+ "write_redacted_trace",
49
+ ]
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ DISPOSITION_RANK = {"allowed": 0, "redact": 1, "violation": 2}
5
+
6
+
7
+ def redaction_action(disposition: str) -> str:
8
+ if disposition == "violation":
9
+ return "remove_or_redact_before_this_layer"
10
+ if disposition == "redact":
11
+ return "redact_before_this_layer"
12
+ return "review"
13
+
14
+
15
+ def recommended_boundary_action(disposition: str, layer: str) -> str:
16
+ if disposition == "violation":
17
+ return f"Remove or redact before {layer}."
18
+ if disposition == "redact":
19
+ return f"Redact before {layer}."
20
+ return "Review only; no policy boundary action required."
21
+
22
+
23
+ def worst_disposition(dispositions: list[str]) -> str:
24
+ if not dispositions:
25
+ return "allowed"
26
+ return max(dispositions, key=lambda item: DISPOSITION_RANK[item])
@@ -0,0 +1,210 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Literal, Mapping, Sequence
5
+
6
+ from .actions import redaction_action, recommended_boundary_action, worst_disposition
7
+ from .detectors import detect_candidates
8
+ from .policy import Policy
9
+ from .trace import SUPPORTED_LAYERS, supported_layers_text
10
+
11
+ GuardMode = Literal["report_only", "redact", "block_on_violation"]
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ScanFinding:
16
+ layer: str
17
+ category: str
18
+ value: str
19
+ start: int
20
+ end: int
21
+ confidence: float
22
+ reason: str
23
+ disposition: str
24
+ risk: str
25
+ rule: str
26
+ redaction_action: str
27
+ redaction: str
28
+
29
+ @property
30
+ def span(self) -> dict[str, int]:
31
+ return {"start": self.start, "end": self.end}
32
+
33
+ def to_dict(self) -> dict[str, Any]:
34
+ return {
35
+ "layer": self.layer,
36
+ "category": self.category,
37
+ "value": self.value,
38
+ "span": self.span,
39
+ "confidence": self.confidence,
40
+ "reason": self.reason,
41
+ "policy": {
42
+ "disposition": self.disposition,
43
+ "risk": self.risk,
44
+ "rule": self.rule,
45
+ },
46
+ "redaction": {
47
+ "action": self.redaction_action,
48
+ "suggested_value": self.redaction,
49
+ },
50
+ }
51
+
52
+ def __getitem__(self, key: str) -> Any:
53
+ return self.to_dict()[key]
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class GuardDecision:
58
+ text: str
59
+ layer: str
60
+ mode: GuardMode
61
+ findings: list[ScanFinding]
62
+ redacted_text: str
63
+ has_phi: bool
64
+ has_redactions: bool
65
+ has_violations: bool
66
+ worst_disposition: str
67
+ recommended_action: str
68
+ should_block: bool
69
+ should_redact: bool
70
+
71
+ def to_dict(self) -> dict[str, Any]:
72
+ return {
73
+ "text": self.text,
74
+ "layer": self.layer,
75
+ "mode": self.mode,
76
+ "findings": [finding.to_dict() for finding in self.findings],
77
+ "redacted_text": self.redacted_text,
78
+ "has_phi": self.has_phi,
79
+ "has_redactions": self.has_redactions,
80
+ "has_violations": self.has_violations,
81
+ "worst_disposition": self.worst_disposition,
82
+ "recommended_action": self.recommended_action,
83
+ "should_block": self.should_block,
84
+ "should_redact": self.should_redact,
85
+ }
86
+
87
+ def to_safe_dict(self) -> dict[str, Any]:
88
+ return {
89
+ "layer": self.layer,
90
+ "mode": self.mode,
91
+ "finding_count": len(self.findings),
92
+ "categories": sorted({finding.category for finding in self.findings}),
93
+ "has_phi": self.has_phi,
94
+ "has_redactions": self.has_redactions,
95
+ "has_violations": self.has_violations,
96
+ "worst_disposition": self.worst_disposition,
97
+ "recommended_action": self.recommended_action,
98
+ "should_block": self.should_block,
99
+ "should_redact": self.should_redact,
100
+ "redacted_text": self.redacted_text,
101
+ }
102
+
103
+
104
+ def scan_text(text: str, layer: str, policy: Policy, *, enable_presidio: bool = False) -> list[ScanFinding]:
105
+ if layer not in SUPPORTED_LAYERS:
106
+ raise ValueError(f"unsupported layer {layer!r}; expected one of: {supported_layers_text()}")
107
+
108
+ findings: list[ScanFinding] = []
109
+ for candidate in detect_candidates(text, enable_presidio=enable_presidio):
110
+ decision = policy.decide(candidate.category, layer)
111
+ findings.append(
112
+ ScanFinding(
113
+ layer=layer,
114
+ category=candidate.category,
115
+ value=candidate.value,
116
+ start=candidate.start,
117
+ end=candidate.end,
118
+ confidence=candidate.confidence,
119
+ reason=candidate.reason,
120
+ disposition=decision.disposition,
121
+ risk=decision.risk,
122
+ rule=decision.rule,
123
+ redaction_action=redaction_action(decision.disposition),
124
+ redaction=decision.redaction,
125
+ )
126
+ )
127
+ return findings
128
+
129
+
130
+ def redact_text(text: str, findings: Sequence[ScanFinding | Mapping[str, Any]]) -> str:
131
+ replacements = _non_overlapping_replacements(findings)
132
+ redacted = text
133
+ for start, end, replacement in reversed(replacements):
134
+ redacted = f"{redacted[:start]}{replacement}{redacted[end:]}"
135
+ return redacted
136
+
137
+
138
+ def guard_text(
139
+ text: str,
140
+ layer: str,
141
+ policy: Policy,
142
+ mode: GuardMode = "report_only",
143
+ *,
144
+ enable_presidio: bool = False,
145
+ ) -> GuardDecision:
146
+ if mode not in ("report_only", "redact", "block_on_violation"):
147
+ raise ValueError("mode must be one of: report_only, redact, block_on_violation")
148
+
149
+ findings = scan_text(text, layer, policy, enable_presidio=enable_presidio)
150
+ redacted_text = redact_text(text, findings)
151
+ dispositions = [finding.disposition for finding in findings]
152
+ worst = worst_disposition(dispositions)
153
+ worst_layer = layer if findings else ""
154
+ has_redactions = redacted_text != text
155
+ has_violations = any(disposition == "violation" for disposition in dispositions)
156
+ should_block = mode == "block_on_violation" and has_violations
157
+ should_redact = mode == "redact" and has_redactions
158
+
159
+ return GuardDecision(
160
+ text=text,
161
+ layer=layer,
162
+ mode=mode,
163
+ findings=findings,
164
+ redacted_text=redacted_text,
165
+ has_phi=bool(findings),
166
+ has_redactions=has_redactions,
167
+ has_violations=has_violations,
168
+ worst_disposition=worst,
169
+ recommended_action=recommended_boundary_action(worst, worst_layer) if findings else "No PHI candidates found.",
170
+ should_block=should_block,
171
+ should_redact=should_redact,
172
+ )
173
+
174
+
175
+ def _non_overlapping_replacements(findings: Sequence[ScanFinding | Mapping[str, Any]]) -> list[tuple[int, int, str]]:
176
+ ordered = sorted(
177
+ findings,
178
+ key=lambda finding: (
179
+ _finding_start(finding),
180
+ -(_finding_end(finding) - _finding_start(finding)),
181
+ ),
182
+ )
183
+ replacements: list[tuple[int, int, str]] = []
184
+ occupied_until = -1
185
+ for finding in ordered:
186
+ start = _finding_start(finding)
187
+ end = _finding_end(finding)
188
+ if start < occupied_until:
189
+ continue
190
+ replacements.append((start, end, _finding_redaction(finding)))
191
+ occupied_until = end
192
+ return replacements
193
+
194
+
195
+ def _finding_start(finding: ScanFinding | Mapping[str, Any]) -> int:
196
+ if isinstance(finding, ScanFinding):
197
+ return finding.start
198
+ return int(finding["span"]["start"])
199
+
200
+
201
+ def _finding_end(finding: ScanFinding | Mapping[str, Any]) -> int:
202
+ if isinstance(finding, ScanFinding):
203
+ return finding.end
204
+ return int(finding["span"]["end"])
205
+
206
+
207
+ def _finding_redaction(finding: ScanFinding | Mapping[str, Any]) -> str:
208
+ if isinstance(finding, ScanFinding):
209
+ return finding.redaction
210
+ return str(finding["redaction"]["suggested_value"])
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from .policy import load_policy
8
+ from .project import check_project_config, init_project
9
+ from .redacted_trace import write_redacted_trace
10
+ from .report import build_report, write_json_report, write_markdown_report
11
+ from .trace import load_trace
12
+
13
+ COMMANDS = {"init", "check-config", "scan-trace"}
14
+
15
+
16
+ def main(argv: list[str] | None = None) -> int:
17
+ args_list = list(sys.argv[1:] if argv is None else argv)
18
+ if args_list and args_list[0] not in COMMANDS and args_list[0] not in ("-h", "--help"):
19
+ return _scan_trace(args_list)
20
+
21
+ parser = argparse.ArgumentParser(description="Gate PHI boundary flows and manage project policy config.")
22
+ subparsers = parser.add_subparsers(dest="command", required=True)
23
+
24
+ init_parser = subparsers.add_parser("init", help="Create project policy config files.")
25
+ init_parser.add_argument("--root", type=Path, default=Path.cwd(), help="Project root to initialize.")
26
+ init_parser.add_argument("--policy", type=Path, help="Policy path relative to --root.")
27
+ init_parser.add_argument("--compliance-policy", type=Path, help="Compliance policy path relative to --root.")
28
+ init_parser.add_argument("--force", action="store_true", help="Overwrite existing generated config files.")
29
+
30
+ check_parser = subparsers.add_parser("check-config", help="Validate discovered project config and policies.")
31
+ check_parser.add_argument("--root", type=Path, default=Path.cwd(), help="Start directory for config discovery.")
32
+
33
+ scan_parser = subparsers.add_parser("scan-trace", help="Scan a JSONL trace and write audit outputs.")
34
+ _add_scan_trace_args(scan_parser)
35
+
36
+ args = parser.parse_args(args_list)
37
+ if args.command == "init":
38
+ return _init(args)
39
+ if args.command == "check-config":
40
+ return _check_config(args)
41
+ return _scan_trace_from_args(args)
42
+
43
+
44
+ def _add_scan_trace_args(parser: argparse.ArgumentParser) -> None:
45
+ parser.add_argument("--trace", required=True, type=Path, help="Path to a synthetic JSONL trace.")
46
+ parser.add_argument("--policy", required=True, type=Path, help="Path to a YAML PHI policy.")
47
+ parser.add_argument("--out", required=True, type=Path, help="Path for the Markdown report.")
48
+ parser.add_argument("--json", required=True, type=Path, dest="json_out", help="Path for the JSON report.")
49
+ parser.add_argument("--redacted-trace", type=Path, help="Optional path for a redacted JSONL trace.")
50
+ parser.add_argument(
51
+ "--enable-presidio",
52
+ action="store_true",
53
+ help="Enable optional local Presidio PII detection in addition to built-in regex rules.",
54
+ )
55
+
56
+
57
+ def _scan_trace(argv: list[str]) -> int:
58
+ parser = argparse.ArgumentParser(description="Gate synthetic PHI boundary flows and write audit reports.")
59
+ _add_scan_trace_args(parser)
60
+ args = parser.parse_args(argv)
61
+ return _scan_trace_from_args(args)
62
+
63
+
64
+ def _scan_trace_from_args(args: argparse.Namespace) -> int:
65
+ try:
66
+ events = load_trace(args.trace)
67
+ policy = load_policy(args.policy)
68
+ report = build_report(events, policy, args.trace, args.policy, enable_presidio=args.enable_presidio)
69
+ except (OSError, ValueError) as exc:
70
+ print(f"error: {exc}", file=sys.stderr)
71
+ return 2
72
+
73
+ write_markdown_report(report, args.out)
74
+ write_json_report(report, args.json_out)
75
+ if args.redacted_trace:
76
+ try:
77
+ write_redacted_trace(events, policy, args.redacted_trace, enable_presidio=args.enable_presidio)
78
+ except ValueError as exc:
79
+ print(f"error: {exc}", file=sys.stderr)
80
+ return 2
81
+
82
+ summary = report["summary"]
83
+ redacted_note = f" and redacted trace {args.redacted_trace}" if args.redacted_trace else ""
84
+ print(
85
+ "Wrote {total} PHI candidate finding(s): {markdown} and {json}{redacted}".format(
86
+ total=summary["total_findings"],
87
+ markdown=args.out,
88
+ json=args.json_out,
89
+ redacted=redacted_note,
90
+ )
91
+ )
92
+ return 0
93
+
94
+
95
+ def _init(args: argparse.Namespace) -> int:
96
+ try:
97
+ config = init_project(
98
+ args.root,
99
+ policy_path=args.policy,
100
+ compliance_policy_path=args.compliance_policy,
101
+ force=args.force,
102
+ )
103
+ except (OSError, ValueError) as exc:
104
+ print(f"error: {exc}", file=sys.stderr)
105
+ return 2
106
+ print(f"Wrote PHI Boundary Gate config: {config.config_path}")
107
+ print(f"Policy: {config.policy_path}")
108
+ if config.compliance_policy_path:
109
+ print(f"Compliance policy: {config.compliance_policy_path}")
110
+ return 0
111
+
112
+
113
+ def _check_config(args: argparse.Namespace) -> int:
114
+ try:
115
+ config = check_project_config(args.root)
116
+ except (OSError, ValueError) as exc:
117
+ print(f"error: {exc}", file=sys.stderr)
118
+ return 2
119
+ print(f"Config ok: {config.config_path}")
120
+ print(f"Policy ok: {config.policy_path}")
121
+ if config.compliance_policy_path:
122
+ print(f"Compliance policy ok: {config.compliance_policy_path}")
123
+ return 0
124
+
125
+
126
+ if __name__ == "__main__":
127
+ raise SystemExit(main())