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 +7 -0
- evoctx/audit.py +159 -0
- evoctx/cli.py +1190 -0
- evoctx/db.py +467 -0
- evoctx/grants.py +199 -0
- evoctx/install.py +328 -0
- evoctx/paths.py +28 -0
- evoctx/policy.py +289 -0
- evoctx/redact.py +66 -0
- evoctx/secrets.py +48 -0
- evoctx/server.py +209 -0
- evoctx/synonyms.py +65 -0
- evoctx-0.1.0.dist-info/METADATA +375 -0
- evoctx-0.1.0.dist-info/RECORD +17 -0
- evoctx-0.1.0.dist-info/WHEEL +4 -0
- evoctx-0.1.0.dist-info/entry_points.txt +2 -0
- evoctx-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
]
|