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.
- openscript_sdk/__init__.py +217 -0
- openscript_sdk/contracts/__init__.py +82 -0
- openscript_sdk/contracts/interceptor.py +98 -0
- openscript_sdk/contracts/server_types.py +87 -0
- openscript_sdk/contracts/session_graph.py +123 -0
- openscript_sdk/contracts/types.py +76 -0
- openscript_sdk/events/__init__.py +0 -0
- openscript_sdk/events/approvals.py +309 -0
- openscript_sdk/events/models.py +26 -0
- openscript_sdk/events/store.py +79 -0
- openscript_sdk/events/writer.py +100 -0
- openscript_sdk/hooks/claude_code.py +479 -0
- openscript_sdk/hooks/claude_code_example.yaml +62 -0
- openscript_sdk/hooks/claude_code_strict_example.yaml +73 -0
- openscript_sdk/hooks/config.py +144 -0
- openscript_sdk/hooks/settings_example.json +28 -0
- openscript_sdk/hooks/settings_strict_example.json +28 -0
- openscript_sdk/integrations/langchain.py +32 -0
- openscript_sdk/integrations/langgraph.py +32 -0
- openscript_sdk/interceptors/base.py +15 -0
- openscript_sdk/interceptors/event_writer.py +131 -0
- openscript_sdk/interceptors/pii.py +168 -0
- openscript_sdk/interceptors/threat.py +674 -0
- openscript_sdk/logging.py +94 -0
- openscript_sdk/middleware/middleware.py +543 -0
- openscript_sdk/middleware/tool.py +247 -0
- openscript_sdk/observability/__init__.py +11 -0
- openscript_sdk/observability/metrics.py +105 -0
- openscript_sdk/observability/risk.py +54 -0
- openscript_sdk/observability/tracing.py +67 -0
- openscript_sdk/policies/compliance.py +261 -0
- openscript_sdk/policies/config.py +244 -0
- openscript_sdk/policies/harmful_request.py +617 -0
- openscript_sdk/policies/normalize.py +489 -0
- openscript_sdk/policies/output_schema.py +353 -0
- openscript_sdk/policies/policies_example.yaml +36 -0
- openscript_sdk/policies/redaction.py +359 -0
- openscript_sdk/policies/secrets.py +239 -0
- openscript_sdk/policies/semantic.py +348 -0
- openscript_sdk/policies/semantic_head.py +9 -0
- openscript_sdk/policies/session_taint.py +399 -0
- openscript_sdk/policies/taint_store.py +111 -0
- openscript_sdk/policies/tool_firewall.py +434 -0
- openscript_sdk/policies/tools_example.yaml +81 -0
- openscript_sdk/policies/toxicity.py +247 -0
- openscript_sdk/py.typed +0 -0
- openscript_sdk/server/__init__.py +1 -0
- openscript_sdk/server/app.py +153 -0
- openscript_sdk/server/auth.py +19 -0
- openscript_sdk/server/guard.py +126 -0
- openscript_sdk/server/guard_example.yaml +55 -0
- openscript_sdk/server/routers/__init__.py +0 -0
- openscript_sdk/server/routers/approvals.py +69 -0
- openscript_sdk/server/routers/guard.py +231 -0
- openscript_sdk/server/routers/sessions.py +80 -0
- openscript_sdk/server/routers/sse.py +69 -0
- openscript_sdk/server/routers/threat.py +119 -0
- openscript_sdk/server/static/dashboard/index.html +209 -0
- openscript_sdk/server/static/dashboard/session.html +339 -0
- openscript_sdk-0.1.0.dist-info/METADATA +225 -0
- openscript_sdk-0.1.0.dist-info/RECORD +65 -0
- openscript_sdk-0.1.0.dist-info/WHEEL +5 -0
- openscript_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- openscript_sdk-0.1.0.dist-info/licenses/LICENSE +190 -0
- 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)
|