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,359 @@
1
+ """Finding and redacting secrets and PII, with nothing but the standard library.
2
+
3
+ SecretsPolicy and PIIPolicy redact through these, and so can anything that
4
+ holds a string and no pipeline: a hook that rewrites a tool's output before
5
+ the model reads it, for one. Importing this module costs only the regexes it
6
+ compiles, which is why it imports nothing outside the standard library.
7
+
8
+ redact_secrets("key AKIAIOSFODNN7EXAMPLE") # ("key AKIA**********", ["aws_access_key"])
9
+ redact_data({"stdout": text}, pii=True) # every string inside, both kinds
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from collections.abc import Sequence
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Secrets. Each entry: (compiled_regex, label, mask).
21
+ # mask=True keeps a short identifying prefix and stars the rest (e.g.
22
+ # "sk-a**********"); mask=False replaces the whole match with
23
+ # "[REDACTED:<label>]" (multi-line blocks and URLs, where a prefix is useless).
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class _SecretPattern:
29
+ regex: re.Pattern[str]
30
+ label: str
31
+ mask: bool
32
+
33
+
34
+ def _s(pattern: str, label: str, mask: bool = True, flags: int = 0) -> _SecretPattern:
35
+ return _SecretPattern(re.compile(pattern, flags), label, mask)
36
+
37
+
38
+ _MASK_PREFIX_LEN = 4
39
+ _MASK = "**********"
40
+
41
+ _SECRET_PATTERNS: list[_SecretPattern] = [
42
+ # PEM blocks first so their contents aren't partially matched by other patterns
43
+ _s(
44
+ r"-----BEGIN [A-Z ]*PRIVATE KEY( BLOCK)?-----.*?-----END [A-Z ]*PRIVATE KEY( BLOCK)?-----",
45
+ "private_key",
46
+ mask=False,
47
+ flags=re.DOTALL,
48
+ ),
49
+ # AWS access key IDs (AKIA = long-term, ASIA = temporary/STS)
50
+ _s(r"\b(AKIA|ASIA)[A-Z0-9]{16}\b", "aws_access_key"),
51
+ # AWS secret access keys — only when introduced by a recognizable key name,
52
+ # since the raw value (40 base64ish chars) is too generic on its own
53
+ _s(
54
+ r"\baws_?secret(_?access)?_?key['\"]?\s*[:=]\s*['\"]?[A-Za-z0-9/+=]{40}\b",
55
+ "aws_secret_key",
56
+ flags=re.IGNORECASE,
57
+ ),
58
+ # GitHub tokens: classic (ghp_/gho_/ghu_/ghs_/ghr_) and fine-grained PATs
59
+ _s(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b", "github_token"),
60
+ _s(r"\bgithub_pat_[A-Za-z0-9_]{22,}\b", "github_token"),
61
+ # Slack tokens: bot/app/personal/refresh/workspace
62
+ _s(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b", "slack_token"),
63
+ # JWTs: three dot-separated base64url segments, header always starts with eyJ
64
+ _s(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b", "jwt"),
65
+ # Generic sk- API keys (OpenAI/Anthropic/Stripe style)
66
+ _s(r"\bsk-[A-Za-z0-9_-]{20,}\b", "api_key"),
67
+ ]
68
+
69
+ # Internal URLs / private network addresses (road.txt §10). Leaking these in
70
+ # output is the primary concern, but they are scanned in input too.
71
+ _URL_TAIL = r"(?::\d+)?(?:/[^\s\"'<>]*)?"
72
+
73
+ _INTERNAL_URL_PATTERNS: list[_SecretPattern] = [
74
+ # Internal hostname suffixes: *.internal, *.corp, *.local, *.lan, *.intranet
75
+ _s(
76
+ r"\b(?:https?://)?(?:[a-z0-9-]+\.)+(?:internal|corp|local|lan|intranet)\b" + _URL_TAIL,
77
+ "internal_url",
78
+ mask=False,
79
+ flags=re.IGNORECASE,
80
+ ),
81
+ _s(
82
+ r"\b(?:https?://)?localhost\b" + _URL_TAIL,
83
+ "internal_url",
84
+ mask=False,
85
+ flags=re.IGNORECASE,
86
+ ),
87
+ # RFC 1918 IPv4 ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
88
+ _s(r"\b(?:https?://)?10(?:\.\d{1,3}){3}\b" + _URL_TAIL, "internal_url", mask=False),
89
+ _s(
90
+ r"\b(?:https?://)?172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}\b" + _URL_TAIL,
91
+ "internal_url",
92
+ mask=False,
93
+ ),
94
+ _s(r"\b(?:https?://)?192\.168(?:\.\d{1,3}){2}\b" + _URL_TAIL, "internal_url", mask=False),
95
+ # IPv6 loopback (::1), bracketed or bare, not inside a longer address
96
+ _s(
97
+ r"(?:https?://)?\[::1\]" + _URL_TAIL + r"|(?<![0-9A-Fa-f:.])::1(?![0-9A-Fa-f:.])",
98
+ "internal_url",
99
+ mask=False,
100
+ ),
101
+ # IPv6 link-local: fe80::/10
102
+ _s(
103
+ r"\bfe80:(?::[0-9A-Fa-f]{0,4}){1,7}(?:%[0-9A-Za-z]+)?\b",
104
+ "internal_url",
105
+ mask=False,
106
+ flags=re.IGNORECASE,
107
+ ),
108
+ ]
109
+
110
+ _ALL_PATTERNS: list[_SecretPattern] = _SECRET_PATTERNS + _INTERNAL_URL_PATTERNS
111
+
112
+
113
+ def find_secrets(text: str) -> list[tuple[str, str]]:
114
+ """Return list of (matched_text, label) pairs found in *text*.
115
+
116
+ Standalone helper — callable outside a policy pipeline. Scans both
117
+ secret and internal-URL pattern banks, with no allowlist applied.
118
+ """
119
+ found: list[tuple[str, str]] = []
120
+ for pat in _ALL_PATTERNS:
121
+ for m in pat.regex.finditer(text):
122
+ found.append((m.group(), pat.label))
123
+ return found
124
+
125
+
126
+ def _mask_match(match: str) -> str:
127
+ return match[:_MASK_PREFIX_LEN] + _MASK
128
+
129
+
130
+ def _redact_secret_text(
131
+ text: str,
132
+ patterns: list[_SecretPattern],
133
+ allowlist: Sequence[str] = (),
134
+ final: bool = True,
135
+ ) -> tuple[str, list[str]]:
136
+ """Replace matches of *patterns* with masked/redacted forms.
137
+
138
+ Matches containing an allowlisted substring (case-insensitive) are
139
+ left untouched and unreported. final=False (guarded streaming) defers
140
+ matches ending exactly at the end of *text* — they may extend with the
141
+ next chunk, and acting early would leak the extension as plain text.
142
+ Returns (redacted_text, found_labels).
143
+ """
144
+ found_labels: list[str] = []
145
+ allow_lower = [a.lower() for a in allowlist]
146
+
147
+ for pat in patterns:
148
+ current_len = len(text)
149
+
150
+ def _replace(
151
+ m: re.Match[str], pat: _SecretPattern = pat, current_len: int = current_len
152
+ ) -> str:
153
+ matched = m.group()
154
+ if not final and m.end() == current_len:
155
+ return matched # tentative — could extend with the next chunk
156
+ if any(a in matched.lower() for a in allow_lower):
157
+ return matched
158
+ found_labels.append(pat.label)
159
+ if pat.mask:
160
+ return _mask_match(matched)
161
+ return f"[REDACTED:{pat.label}]"
162
+
163
+ text = pat.regex.sub(_replace, text)
164
+
165
+ return text, list(dict.fromkeys(found_labels)) # deduplicate, preserve order
166
+
167
+
168
+ def _walk_redact_secrets(
169
+ obj: Any,
170
+ patterns: list[_SecretPattern],
171
+ allowlist: Sequence[str] = (),
172
+ ) -> tuple[Any, list[str]]:
173
+ """Recursively redact pattern matches from strings inside dicts/lists."""
174
+ all_labels: list[str] = []
175
+ if isinstance(obj, str):
176
+ redacted, labels = _redact_secret_text(obj, patterns, allowlist)
177
+ all_labels.extend(labels)
178
+ return redacted, all_labels
179
+ if isinstance(obj, dict):
180
+ result = {}
181
+ for k, v in obj.items():
182
+ result[k], labels = _walk_redact_secrets(v, patterns, allowlist)
183
+ all_labels.extend(labels)
184
+ return result, all_labels
185
+ if isinstance(obj, list):
186
+ result_list = []
187
+ for item in obj:
188
+ redacted_item, labels = _walk_redact_secrets(item, patterns, allowlist)
189
+ all_labels.extend(labels)
190
+ result_list.append(redacted_item)
191
+ return result_list, all_labels
192
+ return obj, []
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # PII. Every match becomes "[REDACTED:<label>]".
197
+ # ---------------------------------------------------------------------------
198
+
199
+
200
+ @dataclass(frozen=True)
201
+ class _PIIPattern:
202
+ regex: re.Pattern[str]
203
+ label: str
204
+
205
+
206
+ def _p(pattern: str, label: str) -> _PIIPattern:
207
+ return _PIIPattern(re.compile(pattern, re.IGNORECASE), label)
208
+
209
+
210
+ _PII_PATTERNS: list[_PIIPattern] = [
211
+ _p(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b", "email"),
212
+ _p(r"\b(\+?1[\s\-.]?)?\(?\d{3}\)?[\s\-.]?\d{3}[\s\-.]?\d{4}\b", "phone"),
213
+ _p(r"\b\d{3}-\d{2}-\d{4}\b", "ssn"),
214
+ # API / bearer tokens — must appear before generic patterns
215
+ _p(r"\b(sk-|Bearer\s+|token[=:\s]+|api[_\-]?key[=:\s]+)[A-Za-z0-9\-_]{20,}\b", "api_key"),
216
+ # IPv4 addresses
217
+ _p(
218
+ r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b", "ip_address"
219
+ ),
220
+ ]
221
+
222
+ # Credit card: 13-16 digits optionally separated by spaces or dashes.
223
+ # Validated with Luhn check to reduce false positives.
224
+ _CC_RE = re.compile(r"\b(?:\d[ \-]?){13,16}\b")
225
+
226
+
227
+ def _luhn(number: str) -> bool:
228
+ digits = [int(d) for d in number if d.isdigit()]
229
+ digits.reverse()
230
+ total = sum(
231
+ d if i % 2 == 0 else (d * 2 - 9 if d * 2 > 9 else d * 2) for i, d in enumerate(digits)
232
+ )
233
+ return total % 10 == 0
234
+
235
+
236
+ def _find_pii(text: str) -> list[tuple[str, str]]:
237
+ """Return list of (matched_text, label) pairs found in text."""
238
+ found: list[tuple[str, str]] = []
239
+ for pat in _PII_PATTERNS:
240
+ for m in pat.regex.finditer(text):
241
+ found.append((m.group(), pat.label))
242
+ for m in _CC_RE.finditer(text):
243
+ raw = m.group()
244
+ digits_only = re.sub(r"[ \-]", "", raw)
245
+ if len(digits_only) >= 13 and _luhn(digits_only):
246
+ found.append((raw, "credit_card"))
247
+ return found
248
+
249
+
250
+ def _redact_pii_text(text: str, final: bool = True) -> tuple[str, list[str]]:
251
+ """Replace PII matches with [REDACTED:<label>] tokens.
252
+
253
+ final=False (guarded streaming) defers matches ending exactly at the end
254
+ of *text* — they may extend with the next chunk, and acting early would
255
+ leak the extension as plain text. Returns (redacted_text, found_labels).
256
+ """
257
+ found_labels: list[str] = []
258
+
259
+ # Credit cards first (before phone regex can partially match)
260
+ cc_len = len(text)
261
+
262
+ def _cc_replace(m: re.Match[str]) -> str:
263
+ raw = m.group()
264
+ if not final and m.end() == cc_len:
265
+ return raw # tentative — could extend with the next chunk
266
+ digits_only = re.sub(r"[ \-]", "", raw)
267
+ if len(digits_only) >= 13 and _luhn(digits_only):
268
+ found_labels.append("credit_card")
269
+ return "[REDACTED:credit_card]"
270
+ return raw
271
+
272
+ text = _CC_RE.sub(_cc_replace, text)
273
+
274
+ for pat in _PII_PATTERNS:
275
+ current_len = len(text)
276
+
277
+ def _replace(
278
+ m: re.Match[str], label: str = pat.label, current_len: int = current_len
279
+ ) -> str:
280
+ if not final and m.end() == current_len:
281
+ return m.group() # tentative — could extend with the next chunk
282
+ found_labels.append(label)
283
+ return f"[REDACTED:{label}]"
284
+
285
+ text = pat.regex.sub(_replace, text)
286
+
287
+ return text, list(dict.fromkeys(found_labels)) # deduplicate, preserve order
288
+
289
+
290
+ def _walk_redact_pii(obj: Any) -> tuple[Any, list[str]]:
291
+ """Recursively redact PII from strings inside dicts/lists."""
292
+ all_labels: list[str] = []
293
+ if isinstance(obj, str):
294
+ redacted, labels = _redact_pii_text(obj)
295
+ all_labels.extend(labels)
296
+ return redacted, all_labels
297
+ if isinstance(obj, dict):
298
+ result = {}
299
+ for k, v in obj.items():
300
+ result[k], labels = _walk_redact_pii(v)
301
+ all_labels.extend(labels)
302
+ return result, all_labels
303
+ if isinstance(obj, list):
304
+ result_list = []
305
+ for item in obj:
306
+ redacted_item, labels = _walk_redact_pii(item)
307
+ all_labels.extend(labels)
308
+ result_list.append(redacted_item)
309
+ return result_list, all_labels
310
+ return obj, []
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # The public helpers
315
+ # ---------------------------------------------------------------------------
316
+
317
+
318
+ def find_pii(text: str) -> list[tuple[str, str]]:
319
+ """(matched_text, label) for each piece of PII in *text*: emails, phone
320
+ numbers, SSNs, API and bearer tokens, IPv4 addresses, and card numbers
321
+ that pass the Luhn check."""
322
+ return _find_pii(text)
323
+
324
+
325
+ def redact_secrets(
326
+ text: str, *, allowlist: Sequence[str] = (), internal_urls: bool = False
327
+ ) -> tuple[str, list[str]]:
328
+ """*text* with every secret masked as SecretsPolicy masks it, and the labels found.
329
+
330
+ Internal URLs and private addresses are left alone unless *internal_urls*:
331
+ localhost and *.local turn up in ordinary code and output all the time.
332
+ A match containing an *allowlist* entry (case-insensitive) is left as it is.
333
+ """
334
+ patterns = _ALL_PATTERNS if internal_urls else _SECRET_PATTERNS
335
+ return _redact_secret_text(text, patterns, allowlist)
336
+
337
+
338
+ def redact_pii(text: str) -> tuple[str, list[str]]:
339
+ """*text* with every piece of PII replaced by "[REDACTED:<label>]", and the labels found."""
340
+ return _redact_pii_text(text)
341
+
342
+
343
+ def redact_data(
344
+ obj: Any, *, secrets: bool = True, pii: bool = False, internal_urls: bool = False
345
+ ) -> tuple[Any, list[str]]:
346
+ """Every string inside *obj* (dicts and lists, to any depth) redacted, and the labels found.
347
+
348
+ The shape is kept: keys, list lengths and non-string values come back as
349
+ they went in, so a structured result is still the same structure.
350
+ """
351
+ labels: list[str] = []
352
+ if secrets:
353
+ patterns = _ALL_PATTERNS if internal_urls else _SECRET_PATTERNS
354
+ obj, found = _walk_redact_secrets(obj, patterns)
355
+ labels += found
356
+ if pii:
357
+ obj, found = _walk_redact_pii(obj)
358
+ labels += found
359
+ return obj, list(dict.fromkeys(labels))
@@ -0,0 +1,239 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Sequence
5
+ from enum import StrEnum
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import structlog
9
+
10
+ from openscript_sdk.contracts.interceptor import BasePolicy, GuardScan, StreamGuard
11
+ from openscript_sdk.contracts.server_types import Event, EventType
12
+ from openscript_sdk.contracts.types import ActionContext, FailureMode, InterceptorDecision
13
+ from openscript_sdk.interceptors.pii import PIIMode
14
+ from openscript_sdk.policies.redaction import _INTERNAL_URL_PATTERNS, _SECRET_PATTERNS
15
+ from openscript_sdk.policies.redaction import _redact_secret_text as _redact_text
16
+ from openscript_sdk.policies.redaction import _walk_redact_secrets as _walk_redact
17
+ from openscript_sdk.policies.redaction import find_secrets as find_secrets
18
+
19
+ if TYPE_CHECKING:
20
+ from openscript_sdk.events.writer import EventWriter
21
+
22
+ logger = structlog.get_logger(__name__)
23
+
24
+ # Guarded streaming: longest realistic bounded secret (fat JWTs run long;
25
+ # everything else is well under 100 chars). PEM blocks are unbounded and are
26
+ # handled by their header instead.
27
+ _GUARD_MAX_MATCH_LEN = 512
28
+ _PEM_HEADER_RE = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY( BLOCK)?-----")
29
+
30
+
31
+ class InternalURLMode(StrEnum):
32
+ """How SecretsPolicy treats internal URL / private address findings.
33
+
34
+ Internal hostnames like localhost and *.local appear constantly in
35
+ legitimate agent output (code snippets, dev instructions), so the
36
+ default only annotates metadata instead of mutating or blocking.
37
+ """
38
+
39
+ ANNOTATE = "annotate"
40
+ REDACT = "redact"
41
+ DENY = "deny"
42
+ OFF = "off"
43
+
44
+
45
+ class _SecretsStreamGuard:
46
+ """Incremental scanner backing SecretsPolicy in guarded streams."""
47
+
48
+ name = "SecretsPolicy"
49
+ max_match_len = _GUARD_MAX_MATCH_LEN
50
+
51
+ def __init__(
52
+ self,
53
+ mode: PIIMode,
54
+ internal_url_mode: InternalURLMode,
55
+ allowlist: Sequence[str],
56
+ ) -> None:
57
+ self._mode = mode
58
+ self._internal_url_mode = internal_url_mode
59
+ self._allowlist = allowlist
60
+
61
+ def scan(self, text: str, final: bool = False) -> GuardScan:
62
+ # A private-key header aborts outright, even in redact mode: the
63
+ # block is unbounded, so chunkwise redaction can't contain it.
64
+ if _PEM_HEADER_RE.search(text):
65
+ return GuardScan(
66
+ text=text,
67
+ labels=["private_key"],
68
+ risk=0.9,
69
+ deny=True,
70
+ reason="private key block in streamed output",
71
+ )
72
+
73
+ labels: list[str] = []
74
+ risk = 0.0
75
+ deny = False
76
+
77
+ redacted, secret_labels = _redact_text(text, _SECRET_PATTERNS, final=final)
78
+ if secret_labels:
79
+ labels.extend(secret_labels)
80
+ risk = 0.9
81
+ if self._mode == PIIMode.DENY:
82
+ deny = True
83
+ else:
84
+ text = redacted
85
+
86
+ if self._internal_url_mode != InternalURLMode.OFF:
87
+ redacted, internal_labels = _redact_text(
88
+ text, _INTERNAL_URL_PATTERNS, self._allowlist, final=final
89
+ )
90
+ if internal_labels:
91
+ labels.extend(internal_labels)
92
+ risk = max(risk, 0.5)
93
+ if self._internal_url_mode == InternalURLMode.DENY:
94
+ deny = True
95
+ elif self._internal_url_mode == InternalURLMode.REDACT:
96
+ text = redacted
97
+
98
+ labels = list(dict.fromkeys(labels))
99
+ reason = f"Secrets detected in stream: {', '.join(sorted(set(labels)))}" if deny else ""
100
+ return GuardScan(text=text, labels=labels, risk=risk, deny=deny, reason=reason)
101
+
102
+
103
+ class SecretsPolicy(BasePolicy):
104
+ """Scans input and output for secrets and internal URLs; redacts or blocks.
105
+
106
+ Detects AWS keys, GitHub/Slack tokens, JWTs, private-key blocks, generic
107
+ sk- API keys, and internal URLs (*.internal/*.corp/*.local/*.lan/*.intranet
108
+ hostnames, localhost, RFC 1918 IPv4 ranges, IPv6 loopback and link-local).
109
+
110
+ Runs in both before_action (input scan) and after_action (output scan) —
111
+ internal URLs leaking in responses is the primary concern, but secrets
112
+ pasted into input should not reach the agent either.
113
+
114
+ Secrets follow *mode* (redact/deny). Internal URLs follow the separate
115
+ *internal_url_mode* toggle, defaulting to ANNOTATE (metadata only, no
116
+ mutation or blocking) because localhost and *.local appear routinely in
117
+ legitimate output; matches containing an *internal_url_allowlist* entry
118
+ (case-insensitive substring, e.g. "docs.local") are ignored entirely.
119
+
120
+ Results accumulate in context.metadata["secrets"] using the standardized
121
+ shape (risk keeps the max across input and output phases; a leaked
122
+ credential scores 0.9, an internal-URL-only finding 0.5):
123
+ {"risk": float, "category": "secrets", "found": [labels...], "redacted": bool}
124
+ """
125
+
126
+ failure_mode: FailureMode = FailureMode.FAIL_OPEN
127
+
128
+ def __init__(
129
+ self,
130
+ mode: PIIMode | str = PIIMode.REDACT,
131
+ writer: EventWriter | None = None,
132
+ internal_url_mode: InternalURLMode | str = InternalURLMode.ANNOTATE,
133
+ internal_url_allowlist: Sequence[str] = (),
134
+ ) -> None:
135
+ self._mode = PIIMode(mode)
136
+ self._internal_url_mode = InternalURLMode(internal_url_mode)
137
+ self._internal_url_allowlist = tuple(internal_url_allowlist)
138
+ self._writer = writer
139
+ self._sequence_counters: dict[str, int] = {}
140
+
141
+ async def before_action(self, context: ActionContext) -> ActionContext:
142
+ context.input_data = await self._scan(context, context.input_data, phase="input")
143
+ return context
144
+
145
+ async def after_action(self, context: ActionContext) -> ActionContext:
146
+ context.output_data = await self._scan(context, context.output_data, phase="output")
147
+ return context
148
+
149
+ def stream_guard(self) -> StreamGuard:
150
+ return _SecretsStreamGuard(
151
+ self._mode, self._internal_url_mode, self._internal_url_allowlist
152
+ )
153
+
154
+ async def _scan(
155
+ self, context: ActionContext, data: dict[str, Any], phase: str
156
+ ) -> dict[str, Any]:
157
+ meta = context.metadata.setdefault(
158
+ "secrets", {"risk": 0.0, "category": "secrets", "found": [], "redacted": False}
159
+ )
160
+
161
+ result = data
162
+ redacted = False
163
+ deny_labels: list[str] = []
164
+
165
+ secret_redacted, secret_labels = _walk_redact(data, _SECRET_PATTERNS)
166
+ if secret_labels:
167
+ if self._mode == PIIMode.DENY:
168
+ deny_labels.extend(secret_labels)
169
+ else:
170
+ result = secret_redacted
171
+ redacted = True
172
+
173
+ internal_labels: list[str] = []
174
+ if self._internal_url_mode != InternalURLMode.OFF:
175
+ internal_redacted, internal_labels = _walk_redact(
176
+ result, _INTERNAL_URL_PATTERNS, self._internal_url_allowlist
177
+ )
178
+ if internal_labels:
179
+ if self._internal_url_mode == InternalURLMode.DENY:
180
+ deny_labels.extend(internal_labels)
181
+ elif self._internal_url_mode == InternalURLMode.REDACT:
182
+ result = internal_redacted
183
+ redacted = True
184
+ # ANNOTATE: metadata/event only, data untouched
185
+
186
+ found_labels = list(dict.fromkeys([*secret_labels, *internal_labels]))
187
+ if not found_labels:
188
+ return data
189
+
190
+ meta["found"] = list(dict.fromkeys([*meta["found"], *found_labels]))
191
+ meta["risk"] = max(meta["risk"], 0.9 if secret_labels else 0.5)
192
+
193
+ logger.warning(
194
+ "secrets_detected",
195
+ session_id=context.session_id,
196
+ agent_id=context.agent_id,
197
+ types=found_labels,
198
+ phase=phase,
199
+ mode=self._mode.value,
200
+ internal_url_mode=self._internal_url_mode.value,
201
+ )
202
+
203
+ if deny_labels:
204
+ context.decision = InterceptorDecision.DENY
205
+ context.decision_reason = (
206
+ f"Secrets detected in {phase}: {', '.join(sorted(set(deny_labels)))}"
207
+ )
208
+ result = data # deny preserves data unmutated
209
+ elif redacted:
210
+ meta["redacted"] = True
211
+
212
+ if self._writer is not None:
213
+ await self._emit_secrets_event(context, found_labels, phase)
214
+
215
+ return result
216
+
217
+ async def _emit_secrets_event(
218
+ self, context: ActionContext, found_labels: list[str], phase: str
219
+ ) -> None:
220
+ seq = self._next_sequence(context.session_id)
221
+ event = Event(
222
+ session_id=context.session_id,
223
+ agent_id=context.agent_id,
224
+ event_type=EventType.POLICY_EVALUATED,
225
+ payload={
226
+ "policy": "secrets_check",
227
+ "secret_types": sorted(set(found_labels)),
228
+ "phase": phase,
229
+ "mode": self._mode.value,
230
+ "decision": context.decision.value,
231
+ },
232
+ sequence_num=seq,
233
+ )
234
+ await self._writer.write(event) # type: ignore[union-attr]
235
+
236
+ def _next_sequence(self, session_id: str) -> int:
237
+ count = self._sequence_counters.get(session_id, 0) + 1
238
+ self._sequence_counters[session_id] = count
239
+ return count