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 +35 -0
- sieve/approval/__init__.py +4 -0
- sieve/approval/base.py +32 -0
- sieve/approval/cli.py +64 -0
- sieve/audit/__init__.py +0 -0
- sieve/audit/log.py +274 -0
- sieve/audit/models.py +29 -0
- sieve/cli.py +129 -0
- sieve/config.py +55 -0
- sieve/core/__init__.py +0 -0
- sieve/core/cost.py +108 -0
- sieve/core/decision.py +50 -0
- sieve/core/errors.py +46 -0
- sieve/core/interceptor.py +271 -0
- sieve/core/similarity.py +161 -0
- sieve/decorator.py +71 -0
- sieve/integrations/__init__.py +0 -0
- sieve/integrations/langchain.py +191 -0
- sieve/integrations/mcp.py +61 -0
- sieve/policy/__init__.py +0 -0
- sieve/policy/engine.py +59 -0
- sieve/policy/loader.py +227 -0
- sieve/policy/models.py +55 -0
- sieve_layer-0.1.0.dist-info/METADATA +387 -0
- sieve_layer-0.1.0.dist-info/RECORD +28 -0
- sieve_layer-0.1.0.dist-info/WHEEL +4 -0
- sieve_layer-0.1.0.dist-info/entry_points.txt +2 -0
- sieve_layer-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
]
|
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"
|
sieve/audit/__init__.py
ADDED
|
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
|