evoctx 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.
evoctx/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """evoctx — local personal context store for AI coding assistants, over MCP. By Evo Bytes."""
2
+ from importlib.metadata import PackageNotFoundError, version
3
+
4
+ try:
5
+ __version__ = version("evoctx")
6
+ except PackageNotFoundError: # running from a source tree without install
7
+ __version__ = "0.0.0.dev0"
evoctx/audit.py ADDED
@@ -0,0 +1,159 @@
1
+ """Audit log — append-only record of every tool call against the store.
2
+
3
+ Each entry: who (client + connection), what (tool, action), the effective
4
+ query (post-redaction — a search string can itself contain secrets), which
5
+ records were touched, and how much left the store. Response *content* is
6
+ never logged — that would duplicate the store.
7
+
8
+ ``connection_id`` is a UUID per server process (one stdio process serves one
9
+ client connection). It is NOT the work-session id from the sessions table:
10
+ connection = transport lifetime, session = unit of work.
11
+
12
+ Append-only is enforced with SQL triggers; anyone with direct file access
13
+ can bypass them — that's the human-only deletion path, not a hole.
14
+ """
15
+ import json
16
+ import sqlite3
17
+ from datetime import datetime, timedelta, timezone
18
+
19
+ AUDIT_SCHEMA = """
20
+ CREATE TABLE IF NOT EXISTS audit_log (
21
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
22
+ ts TEXT NOT NULL,
23
+ connection_id TEXT NOT NULL,
24
+ client TEXT NOT NULL,
25
+ grant_name TEXT,
26
+ tool TEXT NOT NULL,
27
+ action TEXT NOT NULL CHECK(action IN ('read','write','denied','error')),
28
+ query TEXT,
29
+ record_ids TEXT NOT NULL DEFAULT '[]',
30
+ result_count INTEGER NOT NULL DEFAULT 0,
31
+ response_bytes INTEGER NOT NULL DEFAULT 0,
32
+ redactions_applied INTEGER NOT NULL DEFAULT 0
33
+ );
34
+ CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts);
35
+ CREATE INDEX IF NOT EXISTS idx_audit_client_ts ON audit_log(client, ts);
36
+ CREATE INDEX IF NOT EXISTS idx_audit_tool_ts ON audit_log(tool, ts);
37
+
38
+ CREATE TRIGGER IF NOT EXISTS audit_no_update BEFORE UPDATE ON audit_log
39
+ BEGIN SELECT RAISE(ABORT, 'audit_log is append-only'); END;
40
+ CREATE TRIGGER IF NOT EXISTS audit_no_delete BEFORE DELETE ON audit_log
41
+ BEGIN SELECT RAISE(ABORT, 'audit_log is append-only'); END;
42
+ """
43
+
44
+
45
+ def migration_002_audit_log(conn: sqlite3.Connection) -> None:
46
+ conn.executescript(AUDIT_SCHEMA)
47
+
48
+
49
+ class AuditWriter:
50
+ def __init__(self, conn: sqlite3.Connection, connection_id: str):
51
+ self.conn = conn
52
+ self.connection_id = connection_id
53
+
54
+ def log(
55
+ self,
56
+ client: str,
57
+ tool: str,
58
+ action: str,
59
+ grant_name: str | None = None,
60
+ query: dict | None = None,
61
+ record_ids: list[str] | None = None,
62
+ result_count: int = 0,
63
+ response_bytes: int = 0,
64
+ redactions_applied: int = 0,
65
+ ) -> None:
66
+ self.conn.execute(
67
+ """INSERT INTO audit_log
68
+ (ts, connection_id, client, grant_name, tool, action, query,
69
+ record_ids, result_count, response_bytes, redactions_applied)
70
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
71
+ (
72
+ datetime.now(timezone.utc).isoformat(),
73
+ self.connection_id, client, grant_name, tool, action,
74
+ json.dumps(query) if query is not None else None,
75
+ json.dumps(record_ids or []),
76
+ result_count, response_bytes, redactions_applied,
77
+ ),
78
+ )
79
+ self.conn.commit()
80
+
81
+
82
+ _SINCE_UNITS = {"m": "minutes", "h": "hours", "d": "days"}
83
+
84
+
85
+ def parse_since(value: str) -> str:
86
+ """'24h' / '30m' / '7d' -> ISO cutoff timestamp."""
87
+ unit = value[-1]
88
+ if unit not in _SINCE_UNITS:
89
+ raise ValueError(f"bad --since '{value}' (use e.g. 30m, 24h, 7d)")
90
+ amount = int(value[:-1])
91
+ cutoff = datetime.now(timezone.utc) - timedelta(**{_SINCE_UNITS[unit]: amount})
92
+ return cutoff.isoformat()
93
+
94
+
95
+ def prune_audit_log(conn: sqlite3.Connection, before: str) -> int:
96
+ """Deletes audit_log rows with ts < `before` (an ISO cutoff — reuse
97
+ parse_since() to build one from '90d' etc.).
98
+
99
+ The append-only triggers exist to stop a cooperative MCP client from
100
+ tidying its own trail through the tool interface — they were never meant
101
+ to stop the human operator from managing their own disk space. This is
102
+ the one place that distinction is load-bearing: the triggers are
103
+ deliberately dropped and recreated around a single scoped DELETE, never
104
+ reachable from policy.py or any MCP tool path. If this function is ever
105
+ called from anywhere but the CLI, that's the bug to fix."""
106
+ conn.executescript("""
107
+ DROP TRIGGER IF EXISTS audit_no_update;
108
+ DROP TRIGGER IF EXISTS audit_no_delete;
109
+ """)
110
+ try:
111
+ cur = conn.execute("DELETE FROM audit_log WHERE ts < ?", (before,))
112
+ deleted = cur.rowcount
113
+ finally:
114
+ conn.executescript("""
115
+ CREATE TRIGGER IF NOT EXISTS audit_no_update BEFORE UPDATE ON audit_log
116
+ BEGIN SELECT RAISE(ABORT, 'audit_log is append-only'); END;
117
+ CREATE TRIGGER IF NOT EXISTS audit_no_delete BEFORE DELETE ON audit_log
118
+ BEGIN SELECT RAISE(ABORT, 'audit_log is append-only'); END;
119
+ """)
120
+ conn.commit()
121
+ return deleted
122
+
123
+
124
+ def query_audit(
125
+ conn: sqlite3.Connection,
126
+ client: str | None = None,
127
+ tool: str | None = None,
128
+ action: str | None = None,
129
+ since: str | None = None,
130
+ limit: int = 50,
131
+ ) -> list[dict]:
132
+ q = """SELECT ts, connection_id, client, grant_name, tool, action,
133
+ query, record_ids, result_count, response_bytes, redactions_applied
134
+ FROM audit_log WHERE 1=1"""
135
+ params: list = []
136
+ if client:
137
+ q += " AND client = ?"
138
+ params.append(client)
139
+ if tool:
140
+ q += " AND tool = ?"
141
+ params.append(tool)
142
+ if action:
143
+ q += " AND action = ?"
144
+ params.append(action)
145
+ if since:
146
+ q += " AND ts >= ?"
147
+ params.append(parse_since(since))
148
+ q += " ORDER BY ts DESC LIMIT ?"
149
+ params.append(limit)
150
+ return [
151
+ {
152
+ "ts": r[0], "connection_id": r[1], "client": r[2], "grant": r[3],
153
+ "tool": r[4], "action": r[5],
154
+ "query": json.loads(r[6]) if r[6] else None,
155
+ "record_ids": json.loads(r[7]),
156
+ "result_count": r[8], "response_bytes": r[9], "redactions": r[10],
157
+ }
158
+ for r in conn.execute(q, params).fetchall()
159
+ ]