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,399 @@
|
|
|
1
|
+
"""SessionTaintPolicy — what an agent has read decides what it may do next.
|
|
2
|
+
|
|
3
|
+
Every other check here looks at one call. This one remembers. A prompt
|
|
4
|
+
injection does its work in two steps: the agent reads something a stranger
|
|
5
|
+
wrote (a web page, an issue, an email), and then it acts. The detectors try to
|
|
6
|
+
recognise the first step and mostly cannot; measured on attacks nobody wrote a
|
|
7
|
+
pattern for, they catch a few percent. This policy does not try. It notes that
|
|
8
|
+
the session has read untrusted content, and from then on a call that changes
|
|
9
|
+
anything waits for a person, or is refused.
|
|
10
|
+
|
|
11
|
+
It is coarse on purpose. It has no idea whether what was read was hostile, so
|
|
12
|
+
it holds the benign follow-up exactly as it holds the attack. That is the cost,
|
|
13
|
+
and it is the number to look at before turning this on: how often an ordinary
|
|
14
|
+
task reads first and acts second.
|
|
15
|
+
|
|
16
|
+
Nothing here looks at the text of anything, so nothing here can be talked
|
|
17
|
+
round.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import Collection, Mapping
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from enum import StrEnum
|
|
25
|
+
from typing import Any, Protocol
|
|
26
|
+
|
|
27
|
+
from openscript_sdk.contracts.interceptor import BasePolicy
|
|
28
|
+
from openscript_sdk.contracts.types import ActionContext, FailureMode, InterceptorDecision
|
|
29
|
+
|
|
30
|
+
#: In place of a list of names: every tool.
|
|
31
|
+
ANY_TOOL = "*"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TaintAction(StrEnum):
|
|
35
|
+
"""What happens to a state-changing call in a session that has read
|
|
36
|
+
untrusted content."""
|
|
37
|
+
|
|
38
|
+
REQUIRE_APPROVAL = "require_approval"
|
|
39
|
+
DENY = "deny"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class _Tools:
|
|
44
|
+
"""A set of tool names, some of them prefixes: "mcp__github__*" is every
|
|
45
|
+
tool whose name starts with "mcp__github__"."""
|
|
46
|
+
|
|
47
|
+
exact: frozenset[str]
|
|
48
|
+
prefixes: tuple[str, ...]
|
|
49
|
+
|
|
50
|
+
def __contains__(self, name: object) -> bool:
|
|
51
|
+
return isinstance(name, str) and (
|
|
52
|
+
name in self.exact or any(name.startswith(prefix) for prefix in self.prefixes)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _names(value: Collection[str] | str, what: str) -> _Tools | None:
|
|
57
|
+
"""The tool names in *value*, or None when it means every tool."""
|
|
58
|
+
if isinstance(value, str):
|
|
59
|
+
if value == ANY_TOOL:
|
|
60
|
+
return None
|
|
61
|
+
# A string is a collection of its characters. sources="fetch_page"
|
|
62
|
+
# would otherwise become five one-letter tools and match nothing.
|
|
63
|
+
raise TypeError(f'{what} takes a list of tool names, or "*" for every tool; got {value!r}')
|
|
64
|
+
names = frozenset(value)
|
|
65
|
+
if not names:
|
|
66
|
+
# Not a default worth having: with no sources nothing is ever tainted,
|
|
67
|
+
# and with no sinks nothing is ever held. Either way the policy would
|
|
68
|
+
# sit in the list and do nothing.
|
|
69
|
+
raise ValueError(f"{what} is empty, so this policy would never act")
|
|
70
|
+
for name in names:
|
|
71
|
+
if not isinstance(name, str) or not name:
|
|
72
|
+
raise ValueError(f"{what} has to be tool names, got {name!r}")
|
|
73
|
+
if "*" in name[:-1]:
|
|
74
|
+
# A pattern language would be a second way to get a name wrong.
|
|
75
|
+
# A trailing * is all there is: a prefix.
|
|
76
|
+
raise ValueError(f'{what}: "*" may only end a name, as a prefix; got {name!r}')
|
|
77
|
+
prefixes = tuple(sorted(name[:-1] for name in names if name.endswith("*")))
|
|
78
|
+
return _Tools(frozenset(name for name in names if not name.endswith("*")), prefixes)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _known(
|
|
82
|
+
value: Mapping[str, Mapping[str, Collection[str]]] | None, sinks: _Tools | None
|
|
83
|
+
) -> dict[str, dict[str, frozenset[str]]]:
|
|
84
|
+
"""``known`` checked and normalised: tool -> argument -> casefolded values."""
|
|
85
|
+
if value is None:
|
|
86
|
+
return {}
|
|
87
|
+
if not isinstance(value, Mapping) or not value:
|
|
88
|
+
raise ValueError("known maps sink names to their destination arguments, and is not empty")
|
|
89
|
+
known: dict[str, dict[str, frozenset[str]]] = {}
|
|
90
|
+
for tool, args in value.items():
|
|
91
|
+
if not isinstance(tool, str) or not tool:
|
|
92
|
+
raise ValueError(f"known has to be keyed by tool names, got {tool!r}")
|
|
93
|
+
if sinks is not None and tool not in sinks:
|
|
94
|
+
# A destination can only release a held call, and only sinks are held.
|
|
95
|
+
raise ValueError(f"known names {tool!r}, which is not one of the sinks")
|
|
96
|
+
if not isinstance(args, Mapping) or not args:
|
|
97
|
+
raise ValueError(f"known[{tool!r}] maps destination arguments to values")
|
|
98
|
+
known[tool] = {}
|
|
99
|
+
for arg, values in args.items():
|
|
100
|
+
if isinstance(values, str) or not isinstance(values, Collection) or not values:
|
|
101
|
+
# A string is a collection of its characters: "alice@x.com"
|
|
102
|
+
# would become a set of letters and release nothing, silently.
|
|
103
|
+
raise ValueError(f"known[{tool!r}][{arg!r}] takes a list of values")
|
|
104
|
+
if not all(isinstance(v, str) and v for v in values):
|
|
105
|
+
raise ValueError(f"known[{tool!r}][{arg!r}] has to be non-empty strings")
|
|
106
|
+
known[tool][arg] = frozenset(v.casefold() for v in values)
|
|
107
|
+
return known
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _updates(value: Collection[str] | None, known: Mapping[str, Any]) -> frozenset[str]:
|
|
111
|
+
"""``updates`` checked: tools listed in ``known`` that change something
|
|
112
|
+
that already exists, so that leaving out the destination keeps its own."""
|
|
113
|
+
if value is None:
|
|
114
|
+
return frozenset()
|
|
115
|
+
if isinstance(value, str) or not isinstance(value, Collection) or not value:
|
|
116
|
+
# "update_scheduled_transaction" would become a set of its letters.
|
|
117
|
+
raise ValueError("updates takes a non-empty list of tool names")
|
|
118
|
+
if not all(isinstance(name, str) and name for name in value):
|
|
119
|
+
raise ValueError("updates has to be tool names")
|
|
120
|
+
missing = sorted(set(value) - set(known))
|
|
121
|
+
if missing:
|
|
122
|
+
# Only a tool in known can be let through by its destination at all.
|
|
123
|
+
raise ValueError(f"updates names {missing}, which are not in known")
|
|
124
|
+
return frozenset(value)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class TaintStore(Protocol):
|
|
128
|
+
"""Where a SessionTaintPolicy keeps what each session has read.
|
|
129
|
+
|
|
130
|
+
Names only, in the order they first ran. The default keeps them in this
|
|
131
|
+
process; a store that writes them down lets the memory outlive it, which
|
|
132
|
+
is what a hook that runs as a new process on every call needs.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def sources(self, session_id: str) -> list[str]: ...
|
|
136
|
+
|
|
137
|
+
def add(self, session_id: str, source: str) -> None: ...
|
|
138
|
+
|
|
139
|
+
def clear(self, session_id: str) -> None: ...
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class InMemoryTaintStore:
|
|
143
|
+
"""What each session has read, in a dict in this process."""
|
|
144
|
+
|
|
145
|
+
def __init__(self) -> None:
|
|
146
|
+
self._read: dict[str, list[str]] = {}
|
|
147
|
+
|
|
148
|
+
def sources(self, session_id: str) -> list[str]:
|
|
149
|
+
return list(self._read.get(session_id, ()))
|
|
150
|
+
|
|
151
|
+
def add(self, session_id: str, source: str) -> None:
|
|
152
|
+
read = self._read.setdefault(session_id, [])
|
|
153
|
+
if source not in read:
|
|
154
|
+
read.append(source)
|
|
155
|
+
|
|
156
|
+
def clear(self, session_id: str) -> None:
|
|
157
|
+
self._read.pop(session_id, None)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class SessionTaintPolicy(BasePolicy):
|
|
161
|
+
"""Holds state-changing tool calls once a session has read untrusted content.
|
|
162
|
+
|
|
163
|
+
taint = SessionTaintPolicy(
|
|
164
|
+
sources=["fetch_issue", "read_page"], # what they return, a stranger may have written
|
|
165
|
+
sinks=["send_email", "run_shell"], # what they do cannot be taken back
|
|
166
|
+
)
|
|
167
|
+
fetch = SecureTool(fetch_issue, [firewall, taint])
|
|
168
|
+
send = SecureTool(send_email, [firewall, taint])
|
|
169
|
+
|
|
170
|
+
Until a source has run in a session, the sinks run as they always did.
|
|
171
|
+
After one has, each call to a sink raises ActionBlockedError with an
|
|
172
|
+
approval_id (or is refused outright with ``on_taint="deny"``). An approval
|
|
173
|
+
covers one call with those exact arguments; the next one waits again. A
|
|
174
|
+
session does not become clean by being approved.
|
|
175
|
+
|
|
176
|
+
Only acts when context.action == "tool_call", so like ToolFirewallPolicy
|
|
177
|
+
it belongs in a SecureTool's list and does nothing in a SecureAgent's.
|
|
178
|
+
|
|
179
|
+
Three things to get right, because getting them wrong is silent:
|
|
180
|
+
|
|
181
|
+
- **One instance for all of a session's tools.** What a session has read is
|
|
182
|
+
remembered on the instance. Two instances are two memories, and a sink
|
|
183
|
+
wrapped with one never hears about a read seen by the other.
|
|
184
|
+
- **A tool that is not listed is not held.** ``sinks="*"`` holds every
|
|
185
|
+
tool. A tool that reads but whose arguments can carry data out (a URL to
|
|
186
|
+
fetch) is a source and a sink: list it in both.
|
|
187
|
+
- **Sessions are what you say they are.** Calls made without a session_id
|
|
188
|
+
share SecureTool's default one, which errs on the side of holding.
|
|
189
|
+
|
|
190
|
+
The memory is this process's by default, like InMemoryApprovalStore: it is
|
|
191
|
+
not shared between workers and does not survive a restart. Pass a
|
|
192
|
+
``store`` (a TaintStore) to keep it somewhere that does. ``reset(session_id)``
|
|
193
|
+
is for when the model's context has been thrown away, and only then.
|
|
194
|
+
|
|
195
|
+
A name in ``sources`` or ``sinks`` that ends in ``*`` is a prefix:
|
|
196
|
+
``"mcp__github__*"`` is every tool from that MCP server.
|
|
197
|
+
|
|
198
|
+
``known`` lets a held call through when everywhere it sends is somewhere
|
|
199
|
+
the user has already sent: ``known={"send_money": {"recipient": [...]}}``.
|
|
200
|
+
For a listed sink, every destination argument that is present must have
|
|
201
|
+
all of its values known (each item of a list). Values compare exactly,
|
|
202
|
+
ignoring case. A call with none of its destination arguments sends
|
|
203
|
+
nowhere new only if it changes something that already exists, such as an
|
|
204
|
+
existing payment's amount: list those tools in ``updates`` and such a call
|
|
205
|
+
keeps the destination it had and is let through. Any other call with no
|
|
206
|
+
destination, such as a calendar event created with no participants, is
|
|
207
|
+
held: having no destination is not the same as a known one.
|
|
208
|
+
Arguments not listed are not looked at, so list every one that names a
|
|
209
|
+
destination (``to``, ``cc`` and ``bcc``). Sinks not in ``known`` are held as
|
|
210
|
+
before. The list is fixed when the policy is built: an approval does not
|
|
211
|
+
add to it.
|
|
212
|
+
|
|
213
|
+
Results are written to context.metadata["session_taint"] in the standard
|
|
214
|
+
shape: "risk" (held 0.6, refused 0.8, otherwise 0.0), "category", plus
|
|
215
|
+
"tainted", "tainted_by" (the sources that ran), "source", "sink",
|
|
216
|
+
"allowed", "requires_approval" and "reason".
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
failure_mode: FailureMode = FailureMode.FAIL_CLOSED
|
|
220
|
+
|
|
221
|
+
def __init__(
|
|
222
|
+
self,
|
|
223
|
+
*,
|
|
224
|
+
sources: Collection[str] | str,
|
|
225
|
+
sinks: Collection[str] | str,
|
|
226
|
+
on_taint: TaintAction | str = TaintAction.REQUIRE_APPROVAL,
|
|
227
|
+
store: TaintStore | None = None,
|
|
228
|
+
known: Mapping[str, Mapping[str, Collection[str]]] | None = None,
|
|
229
|
+
updates: Collection[str] | None = None,
|
|
230
|
+
) -> None:
|
|
231
|
+
self._sources = _names(sources, "sources")
|
|
232
|
+
self._sinks = _names(sinks, "sinks")
|
|
233
|
+
self._on_taint = TaintAction(on_taint)
|
|
234
|
+
self._known = _known(known, self._sinks)
|
|
235
|
+
self._updates = _updates(updates, self._known)
|
|
236
|
+
# session_id -> the sources that have run in it, in the order they
|
|
237
|
+
# first did. Names only: what they returned is not this policy's to keep.
|
|
238
|
+
self._store: TaintStore = store if store is not None else InMemoryTaintStore()
|
|
239
|
+
|
|
240
|
+
# -- what the host can ask and say -----------------------------------
|
|
241
|
+
|
|
242
|
+
def is_tainted(self, session_id: str) -> bool:
|
|
243
|
+
return bool(self._store.sources(session_id))
|
|
244
|
+
|
|
245
|
+
def tainted_by(self, session_id: str) -> tuple[str, ...]:
|
|
246
|
+
"""The sources that have run in *session_id*, oldest first."""
|
|
247
|
+
return tuple(self._store.sources(session_id))
|
|
248
|
+
|
|
249
|
+
def taint(self, session_id: str, source: str) -> None:
|
|
250
|
+
"""Mark *session_id* as having read untrusted content from *source*.
|
|
251
|
+
|
|
252
|
+
For content that reaches the model by a road that is not a wrapped
|
|
253
|
+
tool: an uploaded file, a retrieved document, a pasted email.
|
|
254
|
+
"""
|
|
255
|
+
if not source:
|
|
256
|
+
raise ValueError("say where the untrusted content came from")
|
|
257
|
+
self._store.add(session_id, source)
|
|
258
|
+
|
|
259
|
+
def reset(self, session_id: str) -> None:
|
|
260
|
+
"""Forget what *session_id* has read.
|
|
261
|
+
|
|
262
|
+
Correct only when the model no longer has it either: the conversation
|
|
263
|
+
was discarded, not merely continued. Text read ten turns ago steers an
|
|
264
|
+
agent as well as text read just now.
|
|
265
|
+
"""
|
|
266
|
+
self._store.clear(session_id)
|
|
267
|
+
|
|
268
|
+
# -- the policy -------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
def _is_source(self, name: str) -> bool:
|
|
271
|
+
# A call with no name cannot be shown not to be one.
|
|
272
|
+
return not name or self._sources is None or name in self._sources
|
|
273
|
+
|
|
274
|
+
def _is_sink(self, name: str) -> bool:
|
|
275
|
+
return not name or self._sinks is None or name in self._sinks
|
|
276
|
+
|
|
277
|
+
@staticmethod
|
|
278
|
+
def _name(context: ActionContext) -> str:
|
|
279
|
+
name = context.input_data.get("name", "")
|
|
280
|
+
return name if isinstance(name, str) else ""
|
|
281
|
+
|
|
282
|
+
def _destinations(self, name: str, args: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
283
|
+
"""The destination arguments of a call to a sink listed in ``known``,
|
|
284
|
+
or None when the sink is not listed."""
|
|
285
|
+
if name not in self._known:
|
|
286
|
+
return None
|
|
287
|
+
args = args if isinstance(args, Mapping) else {}
|
|
288
|
+
return {arg: args[arg] for arg in self._known[name] if args.get(arg) is not None}
|
|
289
|
+
|
|
290
|
+
def _all_known(self, name: str, destinations: dict[str, Any]) -> bool:
|
|
291
|
+
for arg, value in destinations.items():
|
|
292
|
+
items = value if isinstance(value, list | tuple) else [value]
|
|
293
|
+
if not items or not all(
|
|
294
|
+
isinstance(item, str) and item.casefold() in self._known[name][arg]
|
|
295
|
+
for item in items
|
|
296
|
+
):
|
|
297
|
+
return False
|
|
298
|
+
return True
|
|
299
|
+
|
|
300
|
+
def check(
|
|
301
|
+
self, session_id: str, name: str, args: Mapping[str, Any] | None = None
|
|
302
|
+
) -> dict[str, Any]:
|
|
303
|
+
"""Whether a call to *name* in *session_id* is held, without making it.
|
|
304
|
+
|
|
305
|
+
Returns the metadata entry before_action writes: "allowed" False means
|
|
306
|
+
refused, "requires_approval" True means it waits for a person. The
|
|
307
|
+
same answer before_action gives, for a host that only wants the verdict.
|
|
308
|
+
*args* matter only for a sink listed in ``known``.
|
|
309
|
+
"""
|
|
310
|
+
read = self.tainted_by(session_id)
|
|
311
|
+
sink = self._is_sink(name)
|
|
312
|
+
destinations = self._destinations(name, args) if sink else None
|
|
313
|
+
known = destinations is not None and (
|
|
314
|
+
self._all_known(name, destinations) if destinations else name in self._updates
|
|
315
|
+
)
|
|
316
|
+
held = bool(read) and sink and not known
|
|
317
|
+
refused = held and self._on_taint is TaintAction.DENY
|
|
318
|
+
needs_person = held and not refused
|
|
319
|
+
|
|
320
|
+
shown = name or "a call with no name"
|
|
321
|
+
if not held:
|
|
322
|
+
if not read:
|
|
323
|
+
reason = f"'{shown}' is not held: nothing untrusted has been read in this session"
|
|
324
|
+
elif known and not destinations:
|
|
325
|
+
reason = f"'{shown}' is not held: it changes something that exists and keeps its destination"
|
|
326
|
+
elif known:
|
|
327
|
+
reason = f"'{shown}' is not held: it sends only to destinations already known" + (
|
|
328
|
+
f" ({_show(destinations)})" if destinations else ""
|
|
329
|
+
)
|
|
330
|
+
else:
|
|
331
|
+
reason = f"'{shown}' is not one of the calls held after an untrusted read"
|
|
332
|
+
else:
|
|
333
|
+
after = f"this session has read untrusted content (from {', '.join(read)})"
|
|
334
|
+
reason = (
|
|
335
|
+
f"'{shown}' changes state and {after}; it is refused"
|
|
336
|
+
if refused
|
|
337
|
+
else f"'{shown}' changes state and {after}; it needs a person to approve it"
|
|
338
|
+
)
|
|
339
|
+
if destinations == {}:
|
|
340
|
+
reason += " (it names no destination, and is not listed in updates)"
|
|
341
|
+
|
|
342
|
+
entry: dict[str, Any] = {
|
|
343
|
+
"risk": 0.8 if refused else 0.6 if needs_person else 0.0,
|
|
344
|
+
"category": "session_taint",
|
|
345
|
+
"tainted": bool(read),
|
|
346
|
+
"tainted_by": list(read),
|
|
347
|
+
"source": self._is_source(name),
|
|
348
|
+
"sink": sink,
|
|
349
|
+
"allowed": not refused,
|
|
350
|
+
"requires_approval": needs_person,
|
|
351
|
+
"reason": reason,
|
|
352
|
+
}
|
|
353
|
+
if destinations is not None:
|
|
354
|
+
entry["destinations"] = destinations
|
|
355
|
+
entry["known_destination"] = known
|
|
356
|
+
return entry
|
|
357
|
+
|
|
358
|
+
def record(self, session_id: str, name: str) -> bool:
|
|
359
|
+
"""Note that a call to *name* ran in *session_id*. True if it was a
|
|
360
|
+
source, so the session is now tainted by it."""
|
|
361
|
+
if not self._is_source(name):
|
|
362
|
+
return False
|
|
363
|
+
self.taint(session_id, name or "a call with no name")
|
|
364
|
+
return True
|
|
365
|
+
|
|
366
|
+
async def before_action(self, context: ActionContext) -> ActionContext:
|
|
367
|
+
if context.action != "tool_call":
|
|
368
|
+
return context
|
|
369
|
+
args = context.input_data.get("args")
|
|
370
|
+
entry = self.check(
|
|
371
|
+
context.session_id, self._name(context), args if isinstance(args, Mapping) else None
|
|
372
|
+
)
|
|
373
|
+
context.metadata["session_taint"] = entry
|
|
374
|
+
if not entry["allowed"]:
|
|
375
|
+
context.decision = InterceptorDecision.DENY
|
|
376
|
+
context.decision_reason = entry["reason"]
|
|
377
|
+
elif entry["requires_approval"]:
|
|
378
|
+
context.decision = InterceptorDecision.REQUIRE_APPROVAL
|
|
379
|
+
context.decision_reason = entry["reason"]
|
|
380
|
+
return context
|
|
381
|
+
|
|
382
|
+
async def after_action(self, context: ActionContext) -> ActionContext:
|
|
383
|
+
# Reached only when the tool ran: a read that was refused, or that
|
|
384
|
+
# raised, returned nothing and taints nothing. One whose result a later
|
|
385
|
+
# policy withholds still taints. That is the safe side to be wrong on,
|
|
386
|
+
# and this policy cannot see what the policies after it will decide.
|
|
387
|
+
if context.action != "tool_call":
|
|
388
|
+
return context
|
|
389
|
+
if not self.record(context.session_id, self._name(context)):
|
|
390
|
+
return context
|
|
391
|
+
entry = context.metadata.get("session_taint")
|
|
392
|
+
if isinstance(entry, dict):
|
|
393
|
+
entry["tainted"] = True
|
|
394
|
+
entry["tainted_by"] = list(self.tainted_by(context.session_id))
|
|
395
|
+
return context
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _show(destinations: Mapping[str, Any]) -> str:
|
|
399
|
+
return ", ".join(f"{arg}={value!r}" for arg, value in destinations.items())
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""What sessions have read, kept on disk, with nothing but the standard library.
|
|
2
|
+
|
|
3
|
+
SessionTaintPolicy keeps its memory in this process by default. A host that
|
|
4
|
+
runs a fresh process for every tool call, as a Claude Code hook does, needs it
|
|
5
|
+
written down: FileTaintStore keeps one append-only file per session, one
|
|
6
|
+
JSON line per source that ran.
|
|
7
|
+
|
|
8
|
+
Appending a short line with a single write to a file opened O_APPEND does not
|
|
9
|
+
interleave with another process doing the same on a local filesystem, so
|
|
10
|
+
hooks running in parallel need no lock (and none is available under Pyodide).
|
|
11
|
+
On a network filesystem that guarantee does not hold: keep the state
|
|
12
|
+
directory local.
|
|
13
|
+
|
|
14
|
+
Reading fails closed. A line that cannot be read counts as a source: something
|
|
15
|
+
was written there, and a session that read untrusted content must not come
|
|
16
|
+
out of it clean because a record was damaged.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import time
|
|
25
|
+
from collections.abc import Mapping
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
#: What a session id may look like before it becomes part of a file name.
|
|
30
|
+
_SESSION_ID = re.compile(r"[A-Za-z0-9_-]{1,128}")
|
|
31
|
+
|
|
32
|
+
#: The source recorded for a line that cannot be read.
|
|
33
|
+
UNREADABLE = "an unreadable record"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def state_dir(name: str) -> Path:
|
|
37
|
+
"""Where OpenScript keeps state for *name*: $OPENSCRIPT_STATE_DIR, else
|
|
38
|
+
$XDG_STATE_HOME/openscript, else ~/.local/state/openscript."""
|
|
39
|
+
base = os.environ.get("OPENSCRIPT_STATE_DIR")
|
|
40
|
+
if not base:
|
|
41
|
+
xdg = os.environ.get("XDG_STATE_HOME") or str(Path.home() / ".local" / "state")
|
|
42
|
+
base = str(Path(xdg) / "openscript")
|
|
43
|
+
return Path(base) / name
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def append_jsonl(path: Path | str, record: Mapping[str, Any]) -> None:
|
|
47
|
+
"""Append *record* to *path* as one JSON line, in a single write.
|
|
48
|
+
|
|
49
|
+
The file is created readable by its owner only.
|
|
50
|
+
"""
|
|
51
|
+
line = (json.dumps(record, separators=(",", ":"), default=str) + "\n").encode("utf-8")
|
|
52
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
53
|
+
try:
|
|
54
|
+
written = os.write(fd, line)
|
|
55
|
+
while written < len(line): # a regular file writes it all; this is belt and braces
|
|
56
|
+
written += os.write(fd, line[written:])
|
|
57
|
+
finally:
|
|
58
|
+
os.close(fd)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class FileTaintStore:
|
|
62
|
+
"""A TaintStore that keeps each session's sources in ``<directory>/<session_id>.jsonl``."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, directory: Path | str) -> None:
|
|
65
|
+
self._dir = Path(directory)
|
|
66
|
+
self._dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
67
|
+
|
|
68
|
+
def _path(self, session_id: str) -> Path:
|
|
69
|
+
if not isinstance(session_id, str) or not _SESSION_ID.fullmatch(session_id):
|
|
70
|
+
# It becomes a file name: nothing that could climb out of the directory.
|
|
71
|
+
raise ValueError(f"not a session id this store will use as a file name: {session_id!r}")
|
|
72
|
+
return self._dir / f"{session_id}.jsonl"
|
|
73
|
+
|
|
74
|
+
def sources(self, session_id: str) -> list[str]:
|
|
75
|
+
try:
|
|
76
|
+
text = self._path(session_id).read_text(encoding="utf-8", errors="replace")
|
|
77
|
+
except FileNotFoundError:
|
|
78
|
+
return []
|
|
79
|
+
seen: list[str] = []
|
|
80
|
+
for line in text.splitlines():
|
|
81
|
+
if not line.strip():
|
|
82
|
+
continue
|
|
83
|
+
try:
|
|
84
|
+
source = json.loads(line)["source"]
|
|
85
|
+
if not isinstance(source, str) or not source:
|
|
86
|
+
raise ValueError(source)
|
|
87
|
+
except (ValueError, KeyError, TypeError):
|
|
88
|
+
source = UNREADABLE
|
|
89
|
+
if source not in seen:
|
|
90
|
+
seen.append(source)
|
|
91
|
+
return seen
|
|
92
|
+
|
|
93
|
+
def add(self, session_id: str, source: str) -> None:
|
|
94
|
+
append_jsonl(self._path(session_id), {"source": source, "at": time.time()})
|
|
95
|
+
|
|
96
|
+
def clear(self, session_id: str) -> None:
|
|
97
|
+
self._path(session_id).unlink(missing_ok=True)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def prune(directory: Path | str, older_than: float) -> int:
|
|
101
|
+
"""Remove session files not written to for *older_than* seconds. Returns how many."""
|
|
102
|
+
cutoff = time.time() - older_than
|
|
103
|
+
removed = 0
|
|
104
|
+
for path in Path(directory).glob("*.jsonl"):
|
|
105
|
+
try:
|
|
106
|
+
if path.stat().st_mtime < cutoff:
|
|
107
|
+
path.unlink()
|
|
108
|
+
removed += 1
|
|
109
|
+
except FileNotFoundError:
|
|
110
|
+
continue # another process got there first
|
|
111
|
+
return removed
|