openscript-sdk 0.1.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 (65) hide show
  1. openscript_sdk/__init__.py +217 -0
  2. openscript_sdk/contracts/__init__.py +82 -0
  3. openscript_sdk/contracts/interceptor.py +98 -0
  4. openscript_sdk/contracts/server_types.py +87 -0
  5. openscript_sdk/contracts/session_graph.py +123 -0
  6. openscript_sdk/contracts/types.py +76 -0
  7. openscript_sdk/events/__init__.py +0 -0
  8. openscript_sdk/events/approvals.py +309 -0
  9. openscript_sdk/events/models.py +26 -0
  10. openscript_sdk/events/store.py +79 -0
  11. openscript_sdk/events/writer.py +100 -0
  12. openscript_sdk/hooks/claude_code.py +479 -0
  13. openscript_sdk/hooks/claude_code_example.yaml +62 -0
  14. openscript_sdk/hooks/claude_code_strict_example.yaml +73 -0
  15. openscript_sdk/hooks/config.py +144 -0
  16. openscript_sdk/hooks/settings_example.json +28 -0
  17. openscript_sdk/hooks/settings_strict_example.json +28 -0
  18. openscript_sdk/integrations/langchain.py +32 -0
  19. openscript_sdk/integrations/langgraph.py +32 -0
  20. openscript_sdk/interceptors/base.py +15 -0
  21. openscript_sdk/interceptors/event_writer.py +131 -0
  22. openscript_sdk/interceptors/pii.py +168 -0
  23. openscript_sdk/interceptors/threat.py +674 -0
  24. openscript_sdk/logging.py +94 -0
  25. openscript_sdk/middleware/middleware.py +543 -0
  26. openscript_sdk/middleware/tool.py +247 -0
  27. openscript_sdk/observability/__init__.py +11 -0
  28. openscript_sdk/observability/metrics.py +105 -0
  29. openscript_sdk/observability/risk.py +54 -0
  30. openscript_sdk/observability/tracing.py +67 -0
  31. openscript_sdk/policies/compliance.py +261 -0
  32. openscript_sdk/policies/config.py +244 -0
  33. openscript_sdk/policies/harmful_request.py +617 -0
  34. openscript_sdk/policies/normalize.py +489 -0
  35. openscript_sdk/policies/output_schema.py +353 -0
  36. openscript_sdk/policies/policies_example.yaml +36 -0
  37. openscript_sdk/policies/redaction.py +359 -0
  38. openscript_sdk/policies/secrets.py +239 -0
  39. openscript_sdk/policies/semantic.py +348 -0
  40. openscript_sdk/policies/semantic_head.py +9 -0
  41. openscript_sdk/policies/session_taint.py +399 -0
  42. openscript_sdk/policies/taint_store.py +111 -0
  43. openscript_sdk/policies/tool_firewall.py +434 -0
  44. openscript_sdk/policies/tools_example.yaml +81 -0
  45. openscript_sdk/policies/toxicity.py +247 -0
  46. openscript_sdk/py.typed +0 -0
  47. openscript_sdk/server/__init__.py +1 -0
  48. openscript_sdk/server/app.py +153 -0
  49. openscript_sdk/server/auth.py +19 -0
  50. openscript_sdk/server/guard.py +126 -0
  51. openscript_sdk/server/guard_example.yaml +55 -0
  52. openscript_sdk/server/routers/__init__.py +0 -0
  53. openscript_sdk/server/routers/approvals.py +69 -0
  54. openscript_sdk/server/routers/guard.py +231 -0
  55. openscript_sdk/server/routers/sessions.py +80 -0
  56. openscript_sdk/server/routers/sse.py +69 -0
  57. openscript_sdk/server/routers/threat.py +119 -0
  58. openscript_sdk/server/static/dashboard/index.html +209 -0
  59. openscript_sdk/server/static/dashboard/session.html +339 -0
  60. openscript_sdk-0.1.0.dist-info/METADATA +225 -0
  61. openscript_sdk-0.1.0.dist-info/RECORD +65 -0
  62. openscript_sdk-0.1.0.dist-info/WHEEL +5 -0
  63. openscript_sdk-0.1.0.dist-info/entry_points.txt +2 -0
  64. openscript_sdk-0.1.0.dist-info/licenses/LICENSE +190 -0
  65. openscript_sdk-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,144 @@
1
+ """The Claude Code hook's configuration: one YAML file, checked strictly.
2
+
3
+ version: 1
4
+ firewall: {rules: {...}} # ToolRules, or rules_path next to this file
5
+ session: {sources: [...], sinks: [...]} # SessionTaintPolicy, names may end in *
6
+ redact: {secrets: true, pii: false, tools: [...]}
7
+ audit: {path: null, include_args: false}
8
+
9
+ Every section is optional except ``version``, and every key is checked: a
10
+ misspelt one is an error, not a setting quietly ignored. A section that is
11
+ present but would never act (a firewall with no rules, a session rule with no
12
+ sources) is an error too, as it is everywhere else in this package.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+ from typing import Any, Literal
19
+
20
+ import yaml
21
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
22
+
23
+ from openscript_sdk.policies.session_taint import TaintAction
24
+ from openscript_sdk.policies.tool_firewall import ToolRules, load_rules
25
+
26
+ #: Claude Code's own tool names, for telling a typo from an MCP tool.
27
+ BUILT_IN_TOOLS = frozenset(
28
+ {
29
+ "Agent",
30
+ "Bash",
31
+ "Edit",
32
+ "Glob",
33
+ "Grep",
34
+ "MultiEdit",
35
+ "NotebookEdit",
36
+ "Read",
37
+ "Task",
38
+ "TodoWrite",
39
+ "WebFetch",
40
+ "WebSearch",
41
+ "Write",
42
+ }
43
+ )
44
+
45
+
46
+ class ConfigError(Exception):
47
+ """The configuration is missing, unreadable, or would not do what it says."""
48
+
49
+
50
+ class _Section(BaseModel):
51
+ model_config = ConfigDict(extra="forbid")
52
+
53
+
54
+ class FirewallSection(_Section):
55
+ rules: dict[str, Any] = Field(default_factory=dict)
56
+ default_deny: bool = False
57
+ rules_path: str | None = None
58
+
59
+ @model_validator(mode="after")
60
+ def _one_source_of_rules(self) -> FirewallSection:
61
+ if self.rules_path and self.rules:
62
+ raise ValueError("firewall takes rules or rules_path, not both")
63
+ if not self.rules_path and not self.rules:
64
+ raise ValueError("firewall has no rules, so it would never act; remove the section")
65
+ return self
66
+
67
+
68
+ class SessionSection(_Section):
69
+ sources: list[str]
70
+ sinks: list[str]
71
+ on_taint: TaintAction = TaintAction.REQUIRE_APPROVAL
72
+ known: dict[str, dict[str, list[str]]] | None = None
73
+ updates: list[str] | None = None
74
+
75
+
76
+ class RedactSection(_Section):
77
+ secrets: bool = True
78
+ pii: bool = False
79
+ internal_urls: bool = False
80
+ tools: list[str] = Field(
81
+ default_factory=lambda: ["Bash", "Read", "WebFetch", "WebSearch", "mcp__*"]
82
+ )
83
+
84
+ def covers(self, name: str) -> bool:
85
+ return (self.secrets or self.pii or self.internal_urls) and any(
86
+ name == tool or (tool.endswith("*") and name.startswith(tool[:-1]))
87
+ for tool in self.tools
88
+ )
89
+
90
+
91
+ class AuditSection(_Section):
92
+ path: str | None = None
93
+ include_args: bool = False
94
+
95
+
96
+ class HookConfig(_Section):
97
+ version: Literal[1]
98
+ role: str | None = None
99
+ firewall: FirewallSection | None = None
100
+ session: SessionSection | None = None
101
+ redact: RedactSection = Field(default_factory=RedactSection)
102
+ audit: AuditSection = Field(default_factory=AuditSection)
103
+
104
+
105
+ def load_config(path: Path) -> tuple[HookConfig, ToolRules | None]:
106
+ """The config at *path*, and its firewall rules (None when there is no firewall)."""
107
+ try:
108
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
109
+ except FileNotFoundError:
110
+ raise ConfigError(f"no OpenScript config at {path}") from None
111
+ except (OSError, yaml.YAMLError) as error:
112
+ raise ConfigError(f"cannot read the OpenScript config at {path}: {error}") from None
113
+ if not isinstance(raw, dict):
114
+ raise ConfigError(f"the OpenScript config at {path} is empty or not a mapping")
115
+ try:
116
+ config = HookConfig.model_validate(raw)
117
+ rules = None
118
+ if config.firewall is not None:
119
+ if config.firewall.rules_path:
120
+ rules = load_rules(str(path.parent / config.firewall.rules_path))
121
+ else:
122
+ rules = ToolRules.model_validate(
123
+ {"rules": config.firewall.rules, "default_deny": config.firewall.default_deny}
124
+ )
125
+ except (ValidationError, ValueError, OSError) as error:
126
+ raise ConfigError(f"the OpenScript config at {path} is not valid: {error}") from None
127
+ return config, rules
128
+
129
+
130
+ def unknown_names(config: HookConfig, rules: ToolRules | None) -> list[str]:
131
+ """Tool names in the config that are neither Claude Code's nor an MCP tool's.
132
+
133
+ A misspelt name ("Webfetch") matches nothing and nobody is told, so the
134
+ hook's ``check`` command reports them.
135
+ """
136
+ names: set[str] = set(rules.rules) if rules is not None else set()
137
+ if config.session is not None:
138
+ names |= set(config.session.sources) | set(config.session.sinks)
139
+ names |= set(config.redact.tools)
140
+ return sorted(
141
+ name
142
+ for name in names
143
+ if name != "*" and not name.startswith("mcp__") and name.rstrip("*") not in BUILT_IN_TOOLS
144
+ )
@@ -0,0 +1,28 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "^(WebFetch|WebSearch|Write|Edit|MultiEdit|NotebookEdit|mcp__.*)$",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "/ABSOLUTE/PATH/TO/.venv/bin/openscript-hook pre-tool-use || exit 2",
10
+ "timeout": 30
11
+ }
12
+ ]
13
+ }
14
+ ],
15
+ "PostToolUse": [
16
+ {
17
+ "matcher": "^(Bash|Read|WebFetch|WebSearch|mcp__.*)$",
18
+ "hooks": [
19
+ {
20
+ "type": "command",
21
+ "command": "/ABSOLUTE/PATH/TO/.venv/bin/openscript-hook post-tool-use",
22
+ "timeout": 30
23
+ }
24
+ ]
25
+ }
26
+ ]
27
+ }
28
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "^(Bash|WebFetch|WebSearch|Write|Edit|MultiEdit|NotebookEdit|mcp__.*)$",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "/ABSOLUTE/PATH/TO/.venv/bin/openscript-hook pre-tool-use || exit 2",
10
+ "timeout": 30
11
+ }
12
+ ]
13
+ }
14
+ ],
15
+ "PostToolUse": [
16
+ {
17
+ "matcher": "^(Bash|Read|WebFetch|WebSearch|mcp__.*)$",
18
+ "hooks": [
19
+ {
20
+ "type": "command",
21
+ "command": "/ABSOLUTE/PATH/TO/.venv/bin/openscript-hook post-tool-use",
22
+ "timeout": 30
23
+ }
24
+ ]
25
+ }
26
+ ]
27
+ }
28
+ }
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import warnings
4
+ from collections.abc import Sequence
5
+ from typing import Any
6
+
7
+ from openscript_sdk.contracts.interceptor import Policy
8
+ from openscript_sdk.middleware.middleware import SecureAgent
9
+
10
+
11
+ def wrap_agent(
12
+ agent: Any,
13
+ policies: Sequence[Policy] | None = None,
14
+ *,
15
+ interceptors: Sequence[Policy] | None = None,
16
+ ) -> SecureAgent:
17
+ """Wrap a LangChain AgentExecutor with OpenScript policies.
18
+
19
+ Usage:
20
+ from openscript_sdk.integrations.langchain import wrap_agent
21
+ from openscript_sdk import NoopInterceptor
22
+ secure = wrap_agent(agent, policies=[NoopInterceptor()])
23
+ result = await secure.invoke({"input": "hello"})
24
+ """
25
+ if interceptors is not None:
26
+ warnings.warn(
27
+ "interceptors= is deprecated; use policies= instead.",
28
+ DeprecationWarning,
29
+ stacklevel=2,
30
+ )
31
+ policies = policies or interceptors
32
+ return SecureAgent(agent=agent, policies=policies)
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import warnings
4
+ from collections.abc import Sequence
5
+ from typing import Any
6
+
7
+ from openscript_sdk.contracts.interceptor import Policy
8
+ from openscript_sdk.middleware.middleware import SecureAgent
9
+
10
+
11
+ def wrap_graph_agent(
12
+ graph: Any,
13
+ policies: Sequence[Policy] | None = None,
14
+ *,
15
+ interceptors: Sequence[Policy] | None = None,
16
+ ) -> SecureAgent:
17
+ """Wrap a LangGraph compiled graph with OpenScript policies.
18
+
19
+ A named alias for SecureAgent(graph, policies=...) — it proxies the
20
+ graph's whole invoke()/stream() call through the policy pipeline, not
21
+ individual node executions. Nothing here is LangGraph-specific beyond
22
+ the name: any object exposing that same invoke/stream shape works
23
+ identically via SecureAgent directly.
24
+ """
25
+ if interceptors is not None:
26
+ warnings.warn(
27
+ "interceptors= is deprecated; use policies= instead.",
28
+ DeprecationWarning,
29
+ stacklevel=2,
30
+ )
31
+ policies = policies or interceptors
32
+ return SecureAgent(agent=graph, policies=policies)
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from openscript_sdk.contracts.types import ActionContext, FailureMode
4
+
5
+
6
+ class NoopInterceptor:
7
+ """Pass-through interceptor with zero logic. Default when no interceptors configured."""
8
+
9
+ failure_mode: FailureMode = FailureMode.FAIL_OPEN
10
+
11
+ async def before_action(self, context: ActionContext) -> ActionContext:
12
+ return context
13
+
14
+ async def after_action(self, context: ActionContext) -> ActionContext:
15
+ return context
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ import warnings
4
+ from typing import Any
5
+
6
+ import structlog
7
+
8
+ from openscript_sdk.contracts.server_types import Event, EventType
9
+ from openscript_sdk.contracts.types import ActionContext, FailureMode
10
+ from openscript_sdk.events.approvals import action_hash
11
+ from openscript_sdk.events.writer import EventWriter
12
+ from openscript_sdk.observability.risk import aggregate_risk
13
+
14
+ logger = structlog.get_logger(__name__)
15
+
16
+
17
+ class AuditPolicy:
18
+ """Writes before/after events to the event store for every agent action,
19
+ and an ``action_blocked`` event for every action the pipeline refuses.
20
+
21
+ Satisfies the openscript_sdk/contracts/Policy protocol. Lives alongside
22
+ NoopInterceptor in openscript_sdk/interceptors/.
23
+
24
+ A refused action is recorded through on_blocked(), which SecureAgent calls
25
+ wherever this policy sits in the list. That matters because the place it is
26
+ meant to sit is last: a prompt denied by an earlier policy never reaches
27
+ before_action here, and used to leave no event at all.
28
+
29
+ The blocked event carries the input as it stood when the action was
30
+ refused, so redactions made by policies listed *before* the one that denied
31
+ are applied and later ones are not. Pass ``record_blocked_input=False`` to
32
+ store only the input hash when the raw text of a refused prompt should not
33
+ reach the event store.
34
+ """
35
+
36
+ failure_mode: FailureMode = FailureMode.FAIL_OPEN
37
+
38
+ def __init__(self, writer: EventWriter, record_blocked_input: bool = True) -> None:
39
+ self._writer = writer
40
+ self._record_blocked_input = record_blocked_input
41
+ self._sequence_counters: dict[str, int] = {}
42
+
43
+ async def before_action(self, context: ActionContext) -> ActionContext:
44
+ seq = self._next_sequence(context.session_id)
45
+ event = Event(
46
+ session_id=context.session_id,
47
+ agent_id=context.agent_id,
48
+ event_type=EventType.INTERCEPTOR_BEFORE,
49
+ payload={
50
+ "action": context.action,
51
+ "input_data": context.input_data,
52
+ "capabilities": {
53
+ "untrusted_input": context.capabilities.untrusted_input,
54
+ "sensitive_access": context.capabilities.sensitive_access,
55
+ "external_write": context.capabilities.external_write,
56
+ },
57
+ },
58
+ sequence_num=seq,
59
+ )
60
+ await self._writer.write(event)
61
+ return context
62
+
63
+ async def after_action(self, context: ActionContext) -> ActionContext:
64
+ # Computed here (not read from context) because SecureAgent's final
65
+ # aggregation runs after the policy loop; place AuditPolicy last in
66
+ # policies=[...] so the recorded score covers every other policy.
67
+ risk_score, risk_categories = aggregate_risk(context)
68
+ seq = self._next_sequence(context.session_id)
69
+ event = Event(
70
+ session_id=context.session_id,
71
+ agent_id=context.agent_id,
72
+ event_type=EventType.INTERCEPTOR_AFTER,
73
+ payload={
74
+ "action": context.action,
75
+ "output_data": context.output_data,
76
+ "risk_score": risk_score,
77
+ "risk_categories": risk_categories,
78
+ },
79
+ sequence_num=seq,
80
+ )
81
+ await self._writer.write(event)
82
+ return context
83
+
84
+ async def on_blocked(
85
+ self,
86
+ context: ActionContext,
87
+ *,
88
+ blocked_by: str,
89
+ phase: str,
90
+ approval_id: str = "",
91
+ ) -> None:
92
+ payload: dict[str, Any] = {
93
+ "action": context.action,
94
+ "phase": phase,
95
+ "blocked_by": blocked_by,
96
+ "decision": context.decision.value,
97
+ "reason": context.decision_reason,
98
+ # Final: SecureAgent aggregates before it calls this hook.
99
+ "risk_score": context.risk_score,
100
+ "risk_categories": context.risk_categories,
101
+ "input_hash": action_hash(context.action, context.input_data),
102
+ }
103
+ if self._record_blocked_input:
104
+ payload["input_data"] = context.input_data
105
+ if approval_id:
106
+ payload["approval_id"] = approval_id
107
+ event = Event(
108
+ session_id=context.session_id,
109
+ agent_id=context.agent_id,
110
+ event_type=EventType.ACTION_BLOCKED,
111
+ payload=payload,
112
+ sequence_num=self._next_sequence(context.session_id),
113
+ )
114
+ await self._writer.write(event)
115
+
116
+ def _next_sequence(self, session_id: str) -> int:
117
+ count = self._sequence_counters.get(session_id, 0) + 1
118
+ self._sequence_counters[session_id] = count
119
+ return count
120
+
121
+
122
+ class EventWriterInterceptor(AuditPolicy):
123
+ """Deprecated — use AuditPolicy instead."""
124
+
125
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
126
+ warnings.warn(
127
+ "EventWriterInterceptor is deprecated; use AuditPolicy instead.",
128
+ DeprecationWarning,
129
+ stacklevel=2,
130
+ )
131
+ super().__init__(*args, **kwargs)
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ import warnings
4
+ from enum import StrEnum
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ import structlog
8
+
9
+ from openscript_sdk.contracts.interceptor import GuardScan, StreamGuard
10
+ from openscript_sdk.contracts.server_types import Event, EventType
11
+ from openscript_sdk.contracts.types import ActionContext, FailureMode, InterceptorDecision
12
+ from openscript_sdk.policies.redaction import _find_pii as _find_pii
13
+ from openscript_sdk.policies.redaction import _redact_pii_text as _redact_text
14
+ from openscript_sdk.policies.redaction import _walk_redact_pii as _walk_redact
15
+
16
+ if TYPE_CHECKING:
17
+ from openscript_sdk.events.writer import EventWriter
18
+
19
+ logger = structlog.get_logger(__name__)
20
+
21
+
22
+ class PIIMode(StrEnum):
23
+ REDACT = "redact"
24
+ DENY = "deny"
25
+
26
+
27
+ # Labels whose exposure is directly exploitable (identity theft, account
28
+ # takeover); the rest are contact/network identifiers.
29
+ _HIGH_SEVERITY_LABELS = frozenset({"ssn", "credit_card", "api_key"})
30
+
31
+
32
+ def _pii_risk(found_labels: list[str]) -> float:
33
+ if not found_labels:
34
+ return 0.0
35
+ return 0.8 if any(label in _HIGH_SEVERITY_LABELS for label in found_labels) else 0.4
36
+
37
+
38
+ class _PIIStreamGuard:
39
+ """Incremental scanner backing PIIPolicy in guarded streams."""
40
+
41
+ name = "PIIPolicy"
42
+ # generous bound: the longest realistic PII match (emails, spaced card
43
+ # numbers) is well under this
44
+ max_match_len = 256
45
+
46
+ def __init__(self, mode: PIIMode) -> None:
47
+ self._mode = mode
48
+
49
+ def scan(self, text: str, final: bool = False) -> GuardScan:
50
+ redacted, labels = _redact_text(text, final=final)
51
+ if not labels:
52
+ return GuardScan(text=text)
53
+ risk = _pii_risk(labels)
54
+ if self._mode == PIIMode.DENY:
55
+ return GuardScan(
56
+ text=text,
57
+ labels=labels,
58
+ risk=risk,
59
+ deny=True,
60
+ reason=f"PII detected in stream: {', '.join(set(labels))}",
61
+ )
62
+ return GuardScan(text=redacted, labels=labels, risk=risk)
63
+
64
+
65
+ class PIIPolicy:
66
+ """Scans agent output for PII and either redacts or blocks.
67
+
68
+ Runs in after_action. before_action is a pass-through.
69
+ Default mode is REDACT so observability is preserved; use DENY for
70
+ strict environments.
71
+
72
+ Results land in context.metadata["pii"] using the standardized shape:
73
+ {"risk": float, "category": "pii", "found": [labels...], "redacted": bool}
74
+ """
75
+
76
+ failure_mode: FailureMode = FailureMode.FAIL_OPEN
77
+
78
+ def __init__(
79
+ self,
80
+ mode: PIIMode | str = PIIMode.REDACT,
81
+ writer: EventWriter | None = None,
82
+ ) -> None:
83
+ self._mode = PIIMode(mode)
84
+ self._writer = writer
85
+ self._sequence_counters: dict[str, int] = {}
86
+
87
+ async def before_action(self, context: ActionContext) -> ActionContext:
88
+ return context
89
+
90
+ def stream_guard(self) -> StreamGuard:
91
+ return _PIIStreamGuard(self._mode)
92
+
93
+ async def after_action(self, context: ActionContext) -> ActionContext:
94
+ redacted_output, found_labels = _walk_redact(context.output_data)
95
+
96
+ if not found_labels:
97
+ context.metadata["pii"] = {
98
+ "risk": 0.0,
99
+ "category": "pii",
100
+ "found": [],
101
+ "redacted": False,
102
+ }
103
+ return context
104
+
105
+ logger.warning(
106
+ "pii_detected",
107
+ session_id=context.session_id,
108
+ agent_id=context.agent_id,
109
+ types=found_labels,
110
+ mode=self._mode.value,
111
+ )
112
+
113
+ risk = _pii_risk(found_labels)
114
+ if self._mode == PIIMode.DENY:
115
+ context.metadata["pii"] = {
116
+ "risk": risk,
117
+ "category": "pii",
118
+ "found": found_labels,
119
+ "redacted": False,
120
+ }
121
+ context.decision = InterceptorDecision.DENY
122
+ context.decision_reason = f"PII detected in output: {', '.join(set(found_labels))}"
123
+ else:
124
+ context.output_data = redacted_output
125
+ context.metadata["pii"] = {
126
+ "risk": risk,
127
+ "category": "pii",
128
+ "found": found_labels,
129
+ "redacted": True,
130
+ }
131
+
132
+ if self._writer is not None:
133
+ await self._emit_pii_event(context, found_labels)
134
+
135
+ return context
136
+
137
+ async def _emit_pii_event(self, context: ActionContext, found_labels: list[str]) -> None:
138
+ seq = self._next_sequence(context.session_id)
139
+ event = Event(
140
+ session_id=context.session_id,
141
+ agent_id=context.agent_id,
142
+ event_type=EventType.POLICY_EVALUATED,
143
+ payload={
144
+ "policy": "pii_check",
145
+ "pii_types": list(set(found_labels)),
146
+ "mode": self._mode.value,
147
+ "decision": context.decision.value,
148
+ },
149
+ sequence_num=seq,
150
+ )
151
+ await self._writer.write(event) # type: ignore[union-attr]
152
+
153
+ def _next_sequence(self, session_id: str) -> int:
154
+ count = self._sequence_counters.get(session_id, 0) + 1
155
+ self._sequence_counters[session_id] = count
156
+ return count
157
+
158
+
159
+ class PIIInterceptor(PIIPolicy):
160
+ """Deprecated — use PIIPolicy instead."""
161
+
162
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
163
+ warnings.warn(
164
+ "PIIInterceptor is deprecated; use PIIPolicy instead.",
165
+ DeprecationWarning,
166
+ stacklevel=2,
167
+ )
168
+ super().__init__(*args, **kwargs)