sieve-layer 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.
sieve/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """Sieve — drop-in AI agent tool call interceptor.
2
+
3
+ Public API::
4
+
5
+ import sieve
6
+
7
+ # 1. Configure once at startup
8
+ sieve.configure(policy_path="policy.yaml", db_path="audit.db")
9
+
10
+ # 2. Guard plain functions
11
+ @sieve.guard()
12
+ def postgres_query(query: str) -> str:
13
+ ...
14
+
15
+ # 3. Guard LangChain tools
16
+ from sieve.integrations.langchain import wrap_tool
17
+ safe_tool = wrap_tool(my_langchain_tool)
18
+
19
+ # 4. Guard MCP servers
20
+ from sieve.integrations.mcp import SieveMiddleware
21
+ server.add_middleware(SieveMiddleware)
22
+ """
23
+
24
+ from sieve.config import configure, get_interceptor
25
+ from sieve.core.errors import ApprovalDenied, CostLimitExceeded, PolicyViolation
26
+ from sieve.decorator import guard
27
+
28
+ __all__ = [
29
+ "configure",
30
+ "get_interceptor",
31
+ "guard",
32
+ "PolicyViolation",
33
+ "ApprovalDenied",
34
+ "CostLimitExceeded",
35
+ ]
@@ -0,0 +1,4 @@
1
+ from sieve.approval.base import ApprovalHandler, StaticApprovalHandler
2
+ from sieve.approval.cli import CLIApprovalHandler
3
+
4
+ __all__ = ["ApprovalHandler", "CLIApprovalHandler", "StaticApprovalHandler"]
sieve/approval/base.py ADDED
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
4
+
5
+ if TYPE_CHECKING:
6
+ from sieve.core.decision import ToolCall
7
+
8
+
9
+ @runtime_checkable
10
+ class ApprovalHandler(Protocol):
11
+ """Interface for approval mechanisms.
12
+
13
+ Implementations must return True if the operator approves the call,
14
+ False to deny. Should not raise; denial is expressed via the return value.
15
+ """
16
+
17
+ def request(self, call: ToolCall) -> bool:
18
+ """Synchronously request approval for a tool call.
19
+
20
+ Returns True (approved) or False (denied).
21
+ """
22
+ ...
23
+
24
+
25
+ class StaticApprovalHandler:
26
+ """Non-interactive approval handler for tests, CI, and headless agents."""
27
+
28
+ def __init__(self, approve: bool = False) -> None:
29
+ self.approve = approve
30
+
31
+ def request(self, call: ToolCall) -> bool:
32
+ return self.approve
sieve/approval/cli.py ADDED
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import select
5
+ import sys
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from sieve.core.decision import ToolCall
10
+
11
+
12
+ class CLIApprovalHandler:
13
+ """Interactive terminal approval prompt.
14
+
15
+ Blocks execution and asks the operator y/N. Defaults to deny on any
16
+ non-affirmative input, EOF, keyboard interrupt, or timeout.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ timeout_seconds: float | None = None,
22
+ timeout_default: bool = False,
23
+ ) -> None:
24
+ self.timeout_seconds = timeout_seconds
25
+ self.timeout_default = timeout_default
26
+
27
+ def request(self, call: ToolCall) -> bool:
28
+ args_display = json.dumps(call.args, indent=2, default=str)
29
+ print(
30
+ f"\n{'='*60}\n"
31
+ f"[SIEVE] Approval required for tool call:\n"
32
+ f" Tool : {call.name}\n"
33
+ f" Args : {args_display}\n"
34
+ f"{'='*60}",
35
+ file=sys.stderr,
36
+ )
37
+ try:
38
+ answer = self._read_answer().strip().lower()
39
+ except (EOFError, KeyboardInterrupt):
40
+ print("\n[SIEVE] Input interrupted — denying by default.", file=sys.stderr)
41
+ return False
42
+
43
+ approved = answer in ("y", "yes")
44
+ status = "APPROVED" if approved else "DENIED"
45
+ print(f"[SIEVE] {status}.\n", file=sys.stderr)
46
+ return approved
47
+
48
+ def _read_answer(self) -> str:
49
+ prompt = "Approve this call? [y/N]: "
50
+ if self.timeout_seconds is None:
51
+ return input(prompt)
52
+
53
+ print(prompt, end="", flush=True)
54
+ readable, _, _ = select.select([sys.stdin], [], [], self.timeout_seconds)
55
+ if readable:
56
+ return sys.stdin.readline()
57
+
58
+ status = "approving" if self.timeout_default else "denying"
59
+ print(
60
+ f"\n[SIEVE] Approval timed out after {self.timeout_seconds:g}s — "
61
+ f"{status} by default.",
62
+ file=sys.stderr,
63
+ )
64
+ return "y" if self.timeout_default else "n"
File without changes
sieve/audit/log.py ADDED
@@ -0,0 +1,274 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import sqlite3
8
+ import stat
9
+ import threading
10
+ import time
11
+ from contextlib import closing
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from sieve.audit.models import AuditEntry
16
+ from sieve.core.decision import Decision, ToolCall
17
+
18
+ _GENESIS_HASH = "0" * 64
19
+ _SQLITE_TIMEOUT_SECONDS = 30.0
20
+ _SECRET_KEY_RE = re.compile(
21
+ r"(api[_-]?key|authorization|access[_-]?token|auth[_-]?token|bearer|client[_-]?secret|"
22
+ r"password|private[_-]?key|refresh[_-]?token|secret)",
23
+ re.IGNORECASE,
24
+ )
25
+ _SECRET_VALUE_RE = re.compile(
26
+ r"(?i)\b(sk-[A-Za-z0-9_-]{12,}|xox[baprs]-[A-Za-z0-9-]{12,}|"
27
+ r"gh[pousr]_[A-Za-z0-9_]{12,}|Bearer\s+[A-Za-z0-9._~+/=-]{12,})\b"
28
+ )
29
+ _REDACTED = "[REDACTED]"
30
+ _DDL = """
31
+ CREATE TABLE IF NOT EXISTS audit_log (
32
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
33
+ timestamp REAL NOT NULL,
34
+ tool_name TEXT NOT NULL,
35
+ tool_args TEXT NOT NULL,
36
+ outcome TEXT NOT NULL,
37
+ rule_name TEXT,
38
+ approved_by TEXT,
39
+ error TEXT,
40
+ metadata TEXT,
41
+ prev_hash TEXT NOT NULL,
42
+ entry_hash TEXT NOT NULL
43
+ );
44
+ """
45
+ _AUDIT_COLUMNS = (
46
+ "id, timestamp, tool_name, tool_args, outcome, rule_name, approved_by, "
47
+ "error, metadata, prev_hash, entry_hash"
48
+ )
49
+
50
+
51
+ def _canonical_json(fields: dict[str, Any]) -> str:
52
+ """Deterministic JSON serialization with sorted keys and no extra whitespace."""
53
+ return json.dumps(fields, sort_keys=True, separators=(",", ":"), default=str)
54
+
55
+
56
+ def _compute_hash(prev_hash: str, fields: dict[str, Any]) -> str:
57
+ payload = prev_hash + _canonical_json(fields)
58
+ return hashlib.sha256(payload.encode()).hexdigest()
59
+
60
+
61
+ def redact_sensitive(value: Any) -> Any:
62
+ """Return a JSON-like copy with likely credentials removed before auditing."""
63
+ if isinstance(value, dict):
64
+ redacted: dict[str, Any] = {}
65
+ for key, item in value.items():
66
+ key_text = str(key)
67
+ redacted[key] = _REDACTED if _SECRET_KEY_RE.search(key_text) else redact_sensitive(item)
68
+ return redacted
69
+ if isinstance(value, list):
70
+ return [redact_sensitive(item) for item in value]
71
+ if isinstance(value, tuple):
72
+ return [redact_sensitive(item) for item in value]
73
+ if isinstance(value, str):
74
+ return _SECRET_VALUE_RE.sub(_REDACTED, value)
75
+ return value
76
+
77
+
78
+ class AuditLog:
79
+ """Thread-safe, SQLite-backed, hash-chained audit log.
80
+
81
+ Each appended entry captures the sha256 of the previous entry's hash,
82
+ making any retrospective modification detectable via verify_chain().
83
+ """
84
+
85
+ def __init__(self, db_path: str | Path = "sieve_audit.db") -> None:
86
+ self._db_path = str(db_path)
87
+ self._lock = threading.Lock()
88
+ self._ensure_private_db_file()
89
+ self._init_db()
90
+
91
+ def _connect(self) -> sqlite3.Connection:
92
+ conn = sqlite3.connect(
93
+ self._db_path,
94
+ timeout=_SQLITE_TIMEOUT_SECONDS,
95
+ isolation_level=None,
96
+ )
97
+ conn.row_factory = sqlite3.Row
98
+ conn.execute(f"PRAGMA busy_timeout = {int(_SQLITE_TIMEOUT_SECONDS * 1000)}")
99
+ if self._db_path != ":memory:":
100
+ conn.execute("PRAGMA journal_mode = WAL")
101
+ return conn
102
+
103
+ def _ensure_private_db_file(self) -> None:
104
+ if self._db_path == ":memory:":
105
+ return
106
+ path = Path(self._db_path)
107
+ if path.exists() and path.is_dir():
108
+ raise ValueError(f"Audit database path points to a directory: {path}")
109
+ path.parent.mkdir(parents=True, exist_ok=True)
110
+ try:
111
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
112
+ except FileExistsError:
113
+ current = stat.S_IMODE(path.stat().st_mode)
114
+ private = current & ~0o077
115
+ if current != private:
116
+ os.chmod(path, private)
117
+ else:
118
+ os.close(fd)
119
+
120
+ def _init_db(self) -> None:
121
+ with closing(self._connect()) as conn:
122
+ conn.execute("BEGIN IMMEDIATE")
123
+ conn.execute(_DDL)
124
+ columns = {
125
+ row["name"]
126
+ for row in conn.execute("PRAGMA table_info(audit_log)").fetchall()
127
+ }
128
+ if "metadata" not in columns:
129
+ conn.execute("ALTER TABLE audit_log ADD COLUMN metadata TEXT")
130
+ conn.commit()
131
+
132
+ def _last_hash(self, conn: sqlite3.Connection) -> str:
133
+ row = conn.execute(
134
+ "SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1"
135
+ ).fetchone()
136
+ return row["entry_hash"] if row else _GENESIS_HASH
137
+
138
+ def append(
139
+ self,
140
+ call: ToolCall,
141
+ decision: Decision,
142
+ *,
143
+ approved_by: str | None = None,
144
+ error: str | None = None,
145
+ metadata: dict[str, Any] | None = None,
146
+ ) -> AuditEntry:
147
+ """Append a new entry to the chain and return the written AuditEntry."""
148
+ with self._lock:
149
+ with closing(self._connect()) as conn:
150
+ conn.execute("BEGIN IMMEDIATE")
151
+ prev_hash = self._last_hash(conn)
152
+ now = time.time()
153
+ audit_args = redact_sensitive(call.args)
154
+ audit_metadata = redact_sensitive(metadata) if metadata is not None else None
155
+ hashable = {
156
+ "timestamp": now,
157
+ "tool_name": call.name,
158
+ "tool_args": audit_args,
159
+ "outcome": decision.outcome.value,
160
+ "rule_name": decision.rule_name,
161
+ "approved_by": approved_by,
162
+ "error": error,
163
+ "metadata": audit_metadata,
164
+ }
165
+ entry_hash = _compute_hash(prev_hash, hashable)
166
+
167
+ cursor = conn.execute(
168
+ """
169
+ INSERT INTO audit_log
170
+ (timestamp, tool_name, tool_args, outcome, rule_name,
171
+ approved_by, error, metadata, prev_hash, entry_hash)
172
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
173
+ """,
174
+ (
175
+ now,
176
+ call.name,
177
+ _canonical_json(audit_args),
178
+ decision.outcome.value,
179
+ decision.rule_name,
180
+ approved_by,
181
+ error,
182
+ _canonical_json(audit_metadata) if audit_metadata is not None else None,
183
+ prev_hash,
184
+ entry_hash,
185
+ ),
186
+ )
187
+ conn.commit()
188
+ return AuditEntry(
189
+ id=cursor.lastrowid,
190
+ timestamp=now,
191
+ tool_name=call.name,
192
+ tool_args=audit_args,
193
+ outcome=decision.outcome.value,
194
+ rule_name=decision.rule_name,
195
+ approved_by=approved_by,
196
+ error=error,
197
+ metadata=audit_metadata,
198
+ prev_hash=prev_hash,
199
+ entry_hash=entry_hash,
200
+ )
201
+
202
+ def verify_chain(self) -> tuple[bool, str]:
203
+ """Verify the integrity of the entire audit chain.
204
+
205
+ Returns (True, message) if valid, (False, description_of_first_violation) if tampered.
206
+ """
207
+ with closing(self._connect()) as conn:
208
+ rows = conn.execute(
209
+ f"SELECT {_AUDIT_COLUMNS} FROM audit_log ORDER BY id ASC"
210
+ ).fetchall()
211
+
212
+ if not rows:
213
+ return True, "Audit log is empty — chain is vacuously valid."
214
+
215
+ prev_hash = _GENESIS_HASH
216
+ for row in rows:
217
+ stored_prev = row["prev_hash"]
218
+ if stored_prev != prev_hash:
219
+ return False, (
220
+ f"Chain broken at id={row['id']}: "
221
+ f"stored prev_hash {stored_prev!r} != expected {prev_hash!r}"
222
+ )
223
+ hashable = {
224
+ "timestamp": row["timestamp"],
225
+ "tool_name": row["tool_name"],
226
+ "tool_args": json.loads(row["tool_args"]),
227
+ "outcome": row["outcome"],
228
+ "rule_name": row["rule_name"],
229
+ "approved_by": row["approved_by"],
230
+ "error": row["error"],
231
+ "metadata": json.loads(row["metadata"]) if row["metadata"] else None,
232
+ }
233
+ expected_hash = _compute_hash(prev_hash, hashable)
234
+ if row["entry_hash"] != expected_hash:
235
+ legacy_hashable = dict(hashable)
236
+ legacy_hashable.pop("metadata")
237
+ legacy_expected_hash = _compute_hash(prev_hash, legacy_hashable)
238
+ if row["entry_hash"] != legacy_expected_hash:
239
+ return False, (
240
+ f"Hash mismatch at id={row['id']}: "
241
+ f"stored {row['entry_hash']!r} != computed {expected_hash!r}"
242
+ )
243
+ prev_hash = row["entry_hash"]
244
+
245
+ return True, f"Chain intact — {len(rows)} entries verified."
246
+
247
+ def tail(self, n: int = 20) -> list[AuditEntry]:
248
+ """Return the n most recent audit entries, oldest first."""
249
+ if isinstance(n, bool) or not isinstance(n, int):
250
+ raise ValueError("AuditLog.tail limit must be an integer.")
251
+ if n < 1:
252
+ raise ValueError("AuditLog.tail limit must be greater than zero.")
253
+
254
+ with closing(self._connect()) as conn:
255
+ rows = conn.execute(
256
+ f"SELECT {_AUDIT_COLUMNS} FROM audit_log ORDER BY id DESC LIMIT ?", (n,)
257
+ ).fetchall()
258
+
259
+ return [
260
+ AuditEntry(
261
+ id=row["id"],
262
+ timestamp=row["timestamp"],
263
+ tool_name=row["tool_name"],
264
+ tool_args=json.loads(row["tool_args"]),
265
+ outcome=row["outcome"],
266
+ rule_name=row["rule_name"],
267
+ approved_by=row["approved_by"],
268
+ error=row["error"],
269
+ metadata=json.loads(row["metadata"]) if row["metadata"] else None,
270
+ prev_hash=row["prev_hash"],
271
+ entry_hash=row["entry_hash"],
272
+ )
273
+ for row in reversed(rows)
274
+ ]
sieve/audit/models.py ADDED
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class AuditEntry:
9
+ """A single record in the tamper-evident audit chain.
10
+
11
+ Fields included in the hash:
12
+ timestamp, tool_name, redacted tool_args, outcome, rule_name,
13
+ approved_by, error, redacted metadata
14
+
15
+ prev_hash: sha256 of the previous entry's entry_hash (64 zero chars for genesis).
16
+ entry_hash: sha256(prev_hash + canonical_json(hashable_fields)).
17
+ """
18
+
19
+ id: int
20
+ timestamp: float
21
+ tool_name: str
22
+ tool_args: dict[str, Any]
23
+ outcome: str # Outcome enum value
24
+ rule_name: str | None
25
+ approved_by: str | None # identifier of who approved (e.g. "cli:operator")
26
+ error: str | None
27
+ metadata: dict[str, Any] | None
28
+ prev_hash: str
29
+ entry_hash: str
sieve/cli.py ADDED
@@ -0,0 +1,129 @@
1
+ """Sieve CLI — inspect and verify the tamper-evident audit log.
2
+
3
+ Commands:
4
+ sieve verify <db> Verify the hash chain integrity of an audit database.
5
+ sieve tail <db> Print the most recent audit entries.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import datetime
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from sieve.audit.models import AuditEntry
16
+
17
+
18
+ def _existing_database_path(raw_path: str | Path) -> Path:
19
+ db_path = Path(raw_path)
20
+ if not db_path.exists():
21
+ raise FileNotFoundError(f"database not found: {db_path}")
22
+ if not db_path.is_file():
23
+ raise ValueError(f"database path is not a file: {db_path}")
24
+ return db_path
25
+
26
+
27
+ def _format_entry(entry: AuditEntry) -> str:
28
+ ts = datetime.datetime.fromtimestamp(entry.timestamp).strftime("%Y-%m-%d %H:%M:%S")
29
+ parts = [
30
+ f"[{entry.id:>6}] {ts} {entry.outcome.upper():<20} tool={entry.tool_name}",
31
+ f" args={json.dumps(entry.tool_args, separators=(',', ':'), default=str)}",
32
+ ]
33
+ if entry.rule_name:
34
+ parts.append(f" rule={entry.rule_name}")
35
+ if entry.approved_by:
36
+ parts.append(f" approved_by={entry.approved_by}")
37
+ if entry.error:
38
+ parts.append(f" error={entry.error}")
39
+ if entry.metadata:
40
+ parts.append(
41
+ " metadata="
42
+ + json.dumps(entry.metadata, sort_keys=True, separators=(",", ":"), default=str)
43
+ )
44
+ parts.append(f" hash={entry.entry_hash[:16]}...")
45
+ return "\n".join(parts)
46
+
47
+
48
+ def cmd_verify(args: argparse.Namespace) -> int:
49
+ try:
50
+ db_path = _existing_database_path(args.db)
51
+ except (FileNotFoundError, ValueError) as exc:
52
+ print(f"Error: {exc}", file=sys.stderr)
53
+ return 1
54
+
55
+ from sieve.audit.log import AuditLog
56
+ log = AuditLog(db_path)
57
+ valid, message = log.verify_chain()
58
+
59
+ if valid:
60
+ print(f"✓ {message}")
61
+ return 0
62
+ else:
63
+ print(f"✗ TAMPER DETECTED: {message}", file=sys.stderr)
64
+ return 2
65
+
66
+
67
+ def cmd_tail(args: argparse.Namespace) -> int:
68
+ try:
69
+ db_path = _existing_database_path(args.db)
70
+ entries = _tail_entries(db_path, args.n)
71
+ except (FileNotFoundError, ValueError) as exc:
72
+ print(f"Error: {exc}", file=sys.stderr)
73
+ return 1
74
+
75
+ if not entries:
76
+ print("Audit log is empty.")
77
+ return 0
78
+
79
+ for entry in entries:
80
+ print(_format_entry(entry))
81
+ print()
82
+
83
+ return 0
84
+
85
+
86
+ def _tail_entries(db_path: Path, limit: int) -> list[AuditEntry]:
87
+ from sieve.audit.log import AuditLog
88
+
89
+ log = AuditLog(db_path)
90
+ return log.tail(n=limit)
91
+
92
+
93
+ def build_parser() -> argparse.ArgumentParser:
94
+ parser = argparse.ArgumentParser(
95
+ prog="sieve",
96
+ description="Sieve audit log inspector",
97
+ )
98
+ sub = parser.add_subparsers(dest="command", required=True)
99
+
100
+ verify_p = sub.add_parser("verify", help="Verify the hash chain integrity")
101
+ verify_p.add_argument("db", help="Path to the SQLite audit database")
102
+
103
+ tail_p = sub.add_parser("tail", help="Print the most recent audit entries")
104
+ tail_p.add_argument("db", help="Path to the SQLite audit database")
105
+ tail_p.add_argument(
106
+ "-n",
107
+ type=int,
108
+ default=20,
109
+ help="Number of entries to show (default: 20)",
110
+ )
111
+
112
+ return parser
113
+
114
+
115
+ def main() -> None:
116
+ parser = build_parser()
117
+ args = parser.parse_args()
118
+
119
+ if args.command == "verify":
120
+ sys.exit(cmd_verify(args))
121
+ elif args.command == "tail":
122
+ sys.exit(cmd_tail(args))
123
+ else:
124
+ parser.print_help()
125
+ sys.exit(1)
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()
sieve/config.py ADDED
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ from sieve.approval.base import ApprovalHandler
7
+ from sieve.approval.cli import CLIApprovalHandler
8
+ from sieve.audit.log import AuditLog
9
+ from sieve.core.cost import TaskCostTracker
10
+ from sieve.core.interceptor import Interceptor
11
+ from sieve.core.similarity import SimilarityCircuitBreaker
12
+ from sieve.policy.engine import PolicyEngine
13
+ from sieve.policy.loader import load_policy
14
+
15
+
16
+ @dataclass
17
+ class SieveConfig:
18
+ policy_path: str | Path
19
+ db_path: str | Path = "sieve_audit.db"
20
+ approval_handler: ApprovalHandler = field(default_factory=CLIApprovalHandler)
21
+
22
+
23
+ _interceptor: Interceptor | None = None
24
+
25
+
26
+ def configure(
27
+ policy_path: str | Path,
28
+ db_path: str | Path = "sieve_audit.db",
29
+ approval_handler: ApprovalHandler | None = None,
30
+ ) -> Interceptor:
31
+ """Initialize the global Sieve interceptor.
32
+
33
+ Must be called once at application startup before any guarded tool executes.
34
+ Calling again replaces the existing interceptor (safe for tests).
35
+ """
36
+ global _interceptor
37
+
38
+ handler = approval_handler if approval_handler is not None else CLIApprovalHandler()
39
+ policy = load_policy(policy_path)
40
+ engine = PolicyEngine(policy)
41
+ audit = AuditLog(db_path)
42
+ circuit_breaker = SimilarityCircuitBreaker(policy.circuit_breakers)
43
+ cost_tracker = TaskCostTracker(policy.max_cost_per_task)
44
+ _interceptor = Interceptor(engine, audit, handler, circuit_breaker, cost_tracker)
45
+ return _interceptor
46
+
47
+
48
+ def get_interceptor() -> Interceptor:
49
+ """Return the global interceptor, raising if configure() has not been called."""
50
+ if _interceptor is None:
51
+ raise RuntimeError(
52
+ "Sieve has not been configured. Call sieve.configure(policy_path=...) "
53
+ "before using any guarded tools."
54
+ )
55
+ return _interceptor
sieve/core/__init__.py ADDED
File without changes