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,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