vaultcompute 0.1.0a1__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 (40) hide show
  1. vaultcompute/__init__.py +18 -0
  2. vaultcompute/__main__.py +3 -0
  3. vaultcompute/audit.py +253 -0
  4. vaultcompute/cli.py +224 -0
  5. vaultcompute/config.py +354 -0
  6. vaultcompute/core/__init__.py +0 -0
  7. vaultcompute/core/capabilities.py +60 -0
  8. vaultcompute/core/compute_attempts.py +39 -0
  9. vaultcompute/core/lineage.py +86 -0
  10. vaultcompute/core/policy.py +29 -0
  11. vaultcompute/core/protection.py +69 -0
  12. vaultcompute/core/rehydrator.py +79 -0
  13. vaultcompute/core/sqlite_store.py +463 -0
  14. vaultcompute/core/table.py +178 -0
  15. vaultcompute/core/tokenizer.py +293 -0
  16. vaultcompute/core/vault.py +188 -0
  17. vaultcompute/errors.py +5 -0
  18. vaultcompute/hooks.py +102 -0
  19. vaultcompute/hosts/__init__.py +15 -0
  20. vaultcompute/hosts/claude_code.py +191 -0
  21. vaultcompute/hosts/codex.py +139 -0
  22. vaultcompute/hosts/common.py +95 -0
  23. vaultcompute/mcp_server.py +198 -0
  24. vaultcompute/ports/__init__.py +0 -0
  25. vaultcompute/ports/policy.py +24 -0
  26. vaultcompute/ports/sandbox.py +19 -0
  27. vaultcompute/ports/token_store.py +68 -0
  28. vaultcompute/proxy.py +467 -0
  29. vaultcompute/py.typed +0 -0
  30. vaultcompute/sandbox/__init__.py +0 -0
  31. vaultcompute/sandbox/subprocess_.py +136 -0
  32. vaultcompute/session.py +120 -0
  33. vaultcompute/tools/__init__.py +0 -0
  34. vaultcompute/tools/vault_compute.py +182 -0
  35. vaultcompute/tools/vault_table.py +163 -0
  36. vaultcompute-0.1.0a1.dist-info/METADATA +307 -0
  37. vaultcompute-0.1.0a1.dist-info/RECORD +40 -0
  38. vaultcompute-0.1.0a1.dist-info/WHEEL +4 -0
  39. vaultcompute-0.1.0a1.dist-info/entry_points.txt +2 -0
  40. vaultcompute-0.1.0a1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,18 @@
1
+ """VaultCompute — privacy proxy for LLM tool calls."""
2
+
3
+ from vaultcompute.config import describe_config
4
+ from vaultcompute.core.capabilities import TableQueryCapability
5
+ from vaultcompute.core.rehydrator import PLACEHOLDER_PROMPT, rehydrate
6
+ from vaultcompute.core.tokenizer import describe_schema
7
+ from vaultcompute.errors import ProtectionError
8
+ from vaultcompute.session import VaultComputeSession
9
+
10
+ __all__ = [
11
+ "PLACEHOLDER_PROMPT",
12
+ "VaultComputeSession",
13
+ "ProtectionError",
14
+ "TableQueryCapability",
15
+ "describe_config",
16
+ "describe_schema",
17
+ "rehydrate",
18
+ ]
@@ -0,0 +1,3 @@
1
+ from vaultcompute.cli import main
2
+
3
+ raise SystemExit(main())
vaultcompute/audit.py ADDED
@@ -0,0 +1,253 @@
1
+ """`vaultcompute audit` — check a transcript against the vault.
2
+
3
+ A privacy tool has an observability problem that most tools do not: **when it
4
+ works, the screen looks exactly the same as when it is not installed.** In
5
+ Mode C especially, the display hook puts real values back before you read them,
6
+ so the frontend cannot tell you whether anything was ever hidden.
7
+
8
+ The ground truth is the transcript — what the model actually received — and the
9
+ answer is not a grep, because knowing whether a value leaked means knowing
10
+ which values were supposed to be hidden. That is what the vault holds.
11
+
12
+ So: for every record in the vault, look for its value in the transcript. A hit
13
+ is a leak. It reports placeholders too, because "no leaks" is also what an
14
+ empty config produces, and the two need telling apart.
15
+
16
+ What it cannot tell you: a field you never declared was never tokenized, so
17
+ there is no vault record and nothing to look for. This audit measures whether
18
+ VaultCompute kept the promises your config made, not whether the config named
19
+ everything it should have.
20
+
21
+ One more case worth naming: a ``vault_compute`` result can be a string
22
+ literal the model already typed into its own code — picking between two
23
+ already-known names based on a hidden comparison, say. That text was never
24
+ secret; it sat in the transcript, in plain sight, in the tool call's own
25
+ arguments, before the token wrapping it even existed. Flagging its later
26
+ reappearance as a leak was a real false positive. It is excluded now —
27
+ reported separately, as "explained" — but only for records ``vault_compute``
28
+ minted, and only when the matched text is provably a literal the model wrote
29
+ itself; a value actually pulled out of ``resolve(...)`` (a computed sum, say)
30
+ is still flagged exactly as before.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import re
37
+ from collections import Counter
38
+ from dataclasses import dataclass, field
39
+ from pathlib import Path
40
+ from typing import Any, Iterator
41
+
42
+ from vaultcompute.core.rehydrator import TOKEN_PATTERN
43
+ from vaultcompute.ports.token_store import TokenStore
44
+
45
+ #: A vault_compute tool call's `code` argument, as it sits in a
46
+ #: transcript. Not scoped to that specific tool by name — a "code" field from
47
+ #: something else would only ever add extra literals to the allow-list, never
48
+ #: remove one, which is the safe direction for a heuristic like this.
49
+ _COMPUTE_CODE = re.compile(r'"code"\s*:\s*"((?:\\.|[^"\\])*)"')
50
+ #: A quoted string literal inside Python source.
51
+ _STRING_LITERAL = re.compile(r"""["']((?:\\.|[^"'\\])*)["']""")
52
+
53
+
54
+ def _literals_the_model_already_wrote(transcript: str) -> set[str]:
55
+ """String literals appearing in any vault_compute call's own code.
56
+
57
+ These were typed by the model, not extracted from the vault — the model
58
+ already had them. Matching one later isn't proof of a leak.
59
+ """
60
+ literals: set[str] = set()
61
+ for call in _COMPUTE_CODE.finditer(transcript):
62
+ try:
63
+ code = json.loads(f'"{call.group(1)}"')
64
+ except json.JSONDecodeError:
65
+ continue
66
+ literals.update(m.group(1) for m in _STRING_LITERAL.finditer(code))
67
+ return literals
68
+
69
+
70
+ #: Values shorter than this are skipped. "Eng", "1", "true" occur in any
71
+ #: transcript for reasons that have nothing to do with a leak, and reporting
72
+ #: them would bury the real finding under noise.
73
+ MIN_INTERESTING = 5
74
+
75
+
76
+ @dataclass
77
+ class Finding:
78
+ token: str
79
+ value: str
80
+ semantic_type: str | None
81
+
82
+
83
+ @dataclass
84
+ class Report:
85
+ records: int = 0
86
+ placeholders_seen: int = 0
87
+ unresolved: set[str] = field(default_factory=set)
88
+ leaks: list[Finding] = field(default_factory=list)
89
+ skipped_as_too_short: int = 0
90
+ #: A vault_compute record's value matched the transcript, but the match is
91
+ #: a literal the model wrote into its own code — not a leak, see
92
+ #: `_literals_the_model_already_wrote`.
93
+ explained: list[Finding] = field(default_factory=list)
94
+ compute_attempts: int = 0
95
+ compute_outcomes: dict[str, int] = field(default_factory=dict)
96
+
97
+ @property
98
+ def suspicious_compute(self) -> bool:
99
+ return self.compute_outcomes.get("blocked", 0) > 0
100
+
101
+ @property
102
+ def passed(self) -> bool:
103
+ """Suitable for CI: neither a value leak nor a blocked probing burst."""
104
+ return self.clean and not self.suspicious_compute
105
+
106
+ @property
107
+ def clean(self) -> bool:
108
+ return not self.leaks
109
+
110
+ def render(self) -> str:
111
+ lines = [
112
+ f"vault records : {self.records}",
113
+ f"placeholders in transcript: {self.placeholders_seen}",
114
+ ]
115
+ if self.compute_attempts:
116
+ outcomes = ", ".join(
117
+ f"{name}={count}" for name, count in sorted(self.compute_outcomes.items())
118
+ )
119
+ lines.append(f"secret compute attempts : {self.compute_attempts} ({outcomes})")
120
+ if self.suspicious_compute:
121
+ lines.append(
122
+ "SUSPICIOUS COMPUTE : the lineage rate limit blocked "
123
+ f"{self.compute_outcomes['blocked']} attempt(s)"
124
+ )
125
+ if self.unresolved:
126
+ lines.append(
127
+ f"placeholders that do not resolve: {len(self.unresolved)} "
128
+ f"(expired, or from another session)"
129
+ )
130
+ if self.skipped_as_too_short:
131
+ lines.append(
132
+ f"values too short to check : {self.skipped_as_too_short} "
133
+ f"(under {MIN_INTERESTING} characters, would match anything)"
134
+ )
135
+ if self.explained:
136
+ lines.append(
137
+ f"matched but explained : {len(self.explained)} "
138
+ f"(the model wrote this text itself, in compute code — not a leak)"
139
+ )
140
+ if self.leaks:
141
+ lines.append("")
142
+ lines.append(f"LEAKED — {len(self.leaks)} hidden value(s) found in the transcript:")
143
+ for f in self.leaks:
144
+ what = f" ({f.semantic_type})" if f.semantic_type else ""
145
+ lines.append(f" {f.token}{what} -> {f.value[:60]}")
146
+ elif self.records == 0:
147
+ lines.append("")
148
+ lines.append(
149
+ "No vault records. Either nothing ran yet, or nothing was declared "
150
+ "— an empty config leaks nothing and protects nothing."
151
+ )
152
+ elif self.placeholders_seen == 0:
153
+ lines.append("")
154
+ lines.append(
155
+ "Vault has records but the transcript has no placeholders. Different "
156
+ "session, or the wrong transcript."
157
+ )
158
+ else:
159
+ lines.append("")
160
+ lines.append("No hidden value appears in the transcript.")
161
+ return "\n".join(lines)
162
+
163
+
164
+ def _searchable(value: Any) -> Iterator[str]:
165
+ """Every scalar inside a value, as text — a table hides one per cell."""
166
+ if isinstance(value, bool) or value is None:
167
+ return
168
+ if isinstance(value, (int, float)):
169
+ yield str(value)
170
+ elif isinstance(value, str):
171
+ yield value
172
+ elif isinstance(value, dict):
173
+ for v in value.values():
174
+ yield from _searchable(v)
175
+ elif isinstance(value, list):
176
+ for v in value:
177
+ yield from _searchable(v)
178
+
179
+
180
+ def audit(transcript: str, store: TokenStore, session_id: str) -> Report:
181
+ report = Report()
182
+ seen = set(TOKEN_PATTERN.findall(transcript))
183
+ report.placeholders_seen = len(seen)
184
+
185
+ records = store.find_by_session(session_id)
186
+ report.records = len(records)
187
+ attempts = store.find_compute_attempts(session_id)
188
+ report.compute_attempts = len(attempts)
189
+ report.compute_outcomes = dict(Counter(attempt.outcome for attempt in attempts))
190
+
191
+ for token in seen:
192
+ if store.get(token) is None:
193
+ report.unresolved.add(token)
194
+
195
+ literals = _literals_the_model_already_wrote(transcript)
196
+
197
+ for record in records:
198
+ for text in _searchable(record.value):
199
+ if len(text) < MIN_INTERESTING:
200
+ report.skipped_as_too_short += 1
201
+ continue
202
+ if text not in transcript:
203
+ continue
204
+ finding = Finding(token=record.token, value=text, semantic_type=record.semantic_type)
205
+ if record.lineage.op == "vault_compute" and text in literals:
206
+ # The model typed this itself; it was never pulled out of the
207
+ # vault. Keep checking the record's other cells rather than
208
+ # stopping here — an explained match on one cell must not hide
209
+ # a genuine leak on another.
210
+ report.explained.append(finding)
211
+ continue
212
+ report.leaks.append(finding)
213
+ break
214
+ return report
215
+
216
+
217
+ def session_ids_in(path: Path) -> list[str]:
218
+ """The session ids a Claude Code transcript belongs to.
219
+
220
+ Saves the caller from knowing one: the file names its own session, and that
221
+ is the id the hooks minted under.
222
+ """
223
+ found: list[str] = []
224
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
225
+ try:
226
+ sid = json.loads(line).get("sessionId")
227
+ except (json.JSONDecodeError, AttributeError):
228
+ continue
229
+ if isinstance(sid, str) and sid not in found:
230
+ found.append(sid)
231
+ return found
232
+
233
+
234
+ def read_transcript(path: Path) -> str:
235
+ """The transcript as one blob.
236
+
237
+ Claude Code writes JSONL; anything else is read as plain text, so this also
238
+ works on a log you captured yourself.
239
+ """
240
+ raw = path.read_text(encoding="utf-8", errors="replace")
241
+ if path.suffix != ".jsonl":
242
+ return raw
243
+ # Re-serialising each line normalises escaping, so a placeholder written as
244
+ # ⟦ in the file is found the same as one written literally.
245
+ out = []
246
+ for line in raw.splitlines():
247
+ if not line.strip():
248
+ continue
249
+ try:
250
+ out.append(json.dumps(json.loads(line), ensure_ascii=False))
251
+ except json.JSONDecodeError:
252
+ out.append(line)
253
+ return "\n".join(out)
vaultcompute/cli.py ADDED
@@ -0,0 +1,224 @@
1
+ """VaultCompute CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from vaultcompute import audit as audit_mod
12
+ from vaultcompute import hooks, mcp_server
13
+ from vaultcompute.config import VaultComputeConfig, build_token_store, load_config
14
+ from vaultcompute.core.policy import SessionBoundPolicy
15
+ from vaultcompute.hosts import CLAUDE_CODE, HOSTS
16
+ from vaultcompute.proxy import run_proxy
17
+
18
+ USAGE = (
19
+ "vaultcompute [--config PATH] -- <downstream-mcp-command> [args...]\n"
20
+ "vaultcompute hook <event> [--host claude-code|codex] [--config PATH]\n"
21
+ "vaultcompute mcp-server [--config PATH]\n"
22
+ "vaultcompute audit <transcript> [--session ID] [--config PATH]\n\n"
23
+ "Wraps the given stdio MCP server, tokenizing declared JSON tool results\n"
24
+ "and exposing configured operation tools + vaultcompute/rehydrate. Strict\n"
25
+ "protocol handling is the default; arbitrary Python is opt-in.\n\n"
26
+ "`hook` reads one Claude Code or Codex hook event as JSON on stdin and writes the\n"
27
+ "hook response on stdout. `mcp-server` exposes the operation tools selected\n"
28
+ "by compute.mode. Both need a shared vault\n"
29
+ "(storage.backend: sqlite): they run as separate processes.\n\n"
30
+ "`audit` checks a transcript against the vault and reports whether any\n"
31
+ "hidden value reached the model — the question the screen cannot answer."
32
+ )
33
+
34
+
35
+ def _split_argv(argv: list[str]) -> tuple[list[str], list[str]]:
36
+ if "--" not in argv:
37
+ return argv, []
38
+ idx = argv.index("--")
39
+ return argv[:idx], argv[idx + 1 :]
40
+
41
+
42
+ def main(argv: list[str] | None = None) -> int:
43
+ # Windows' default console/subprocess codepage cannot represent the token
44
+ # delimiters U+27E6/U+27E7, and nothing upstream sets PYTHONIOENCODING for
45
+ # a `uv tool install`-ed binary a host invokes as a bare command name —
46
+ # which is exactly how the plugin's hooks and MCP server run it. Every
47
+ # subcommand below reads or prints through text-mode stdio, so this forces
48
+ # UTF-8 once, here, rather than corrupting or crashing at the first
49
+ # placeholder touched. Harmless to the proxy's own JSON-RPC path, which
50
+ # reads and writes raw bytes via .buffer and never goes through this text
51
+ # layer at all.
52
+ #
53
+ # stdin needs the same treatment as stdout/stderr, and the failure mode is
54
+ # worse: reading a mis-decoded ⟦ doesn't raise, it silently produces a
55
+ # different character. `hook message-display` still parses as valid JSON
56
+ # and returns cleanly — TOKEN_PATTERN just never matches, so the hook
57
+ # answers "nothing to do" and the host displays the raw placeholder
58
+ # forever. Confirmed by running the exact same call with and without
59
+ # PYTHONIOENCODING set: identical event, one resolves the token, the other
60
+ # produces no output and no error.
61
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
62
+ if hasattr(stream, "reconfigure") and (stream.encoding or "").lower() != "utf-8":
63
+ stream.reconfigure(encoding="utf-8")
64
+
65
+ argv = list(sys.argv[1:] if argv is None else argv)
66
+
67
+ if argv and argv[0] == "hook":
68
+ return run_hook(argv[1:])
69
+
70
+ if argv and argv[0] == "mcp-server":
71
+ return run_mcp_server(argv[1:])
72
+
73
+ if argv and argv[0] == "audit":
74
+ return run_audit(argv[1:])
75
+
76
+ own, downstream = _split_argv(argv)
77
+ parser = argparse.ArgumentParser(
78
+ prog="vaultcompute",
79
+ description="Privacy proxy for stdio MCP servers.",
80
+ usage=USAGE,
81
+ )
82
+ parser.add_argument("--config", type=Path, default=None, help="Path to vaultcompute.yaml")
83
+ args = parser.parse_args(own)
84
+
85
+ if not downstream:
86
+ print("error: no downstream command after `--`", file=sys.stderr)
87
+ print(USAGE, file=sys.stderr)
88
+ return 2
89
+
90
+ asyncio.run(run_proxy(downstream, config_path=args.config))
91
+ return 0
92
+
93
+
94
+ def run_hook(argv: list[str]) -> int:
95
+ """Handle one host lifecycle event.
96
+
97
+ Failure is asymmetric on purpose. Printing nothing tells the host to keep
98
+ what it had: for ``PostToolUse`` that is the untokenized tool result on its
99
+ way to the model, so anything going wrong there has to block instead. For
100
+ ``MessageDisplay`` the worst case is the user reading a placeholder, which
101
+ is a nuisance and not a disclosure, so that one stays quiet.
102
+ """
103
+ parser = argparse.ArgumentParser(prog="vaultcompute hook", usage=USAGE)
104
+ parser.add_argument("event")
105
+ parser.add_argument("--host", choices=list(HOSTS), default=CLAUDE_CODE)
106
+ parser.add_argument("--config", type=Path, default=Path("vaultcompute.yaml"))
107
+ args = parser.parse_args(argv)
108
+
109
+ if args.event not in hooks.events_for(args.host):
110
+ parser.error(
111
+ f"event {args.event!r} is not available for {args.host}; "
112
+ f"choose from {', '.join(hooks.events_for(args.host))}"
113
+ )
114
+
115
+ protects = args.event in (hooks.PRE_TOOL_USE, hooks.POST_TOOL_USE)
116
+
117
+ def fail(message: str) -> int:
118
+ print(f"[vaultcompute] {message}", file=sys.stderr)
119
+ if protects:
120
+ response = hooks.failure_response(args.host, args.event, message)
121
+ if response is not None:
122
+ print(json.dumps(response))
123
+ return 0
124
+
125
+ try:
126
+ config = load_config(args.config) if args.config.exists() else VaultComputeConfig()
127
+ except Exception as exc: # a broken config must not silently disable protection
128
+ return fail(f"could not load {args.config}: {type(exc).__name__}")
129
+
130
+ if config.storage.backend == "memory":
131
+ return fail(
132
+ "hooks need storage.backend: sqlite — each hook run is a separate process, "
133
+ "and a memory vault would not survive between tokenizing and displaying"
134
+ )
135
+
136
+ try:
137
+ event = hooks.read_event(sys.stdin.read())
138
+ except Exception as exc:
139
+ return fail(f"unreadable hook input: {type(exc).__name__}")
140
+
141
+ try:
142
+ store = build_token_store(config)
143
+ except Exception as exc:
144
+ return fail(f"could not open vault: {type(exc).__name__}")
145
+ try:
146
+ response = hooks.dispatch(
147
+ args.event,
148
+ event,
149
+ config=config,
150
+ store=store,
151
+ policy=SessionBoundPolicy(),
152
+ host=args.host,
153
+ )
154
+ except Exception as exc:
155
+ # Only the exception type, never its message: the same reasoning as the
156
+ # compute sandbox, since a message can carry a value it touched.
157
+ return fail(f"{args.event} failed: {type(exc).__name__}")
158
+ finally:
159
+ close = getattr(store, "close", None)
160
+ if close is not None:
161
+ close()
162
+
163
+ if response is not None:
164
+ print(json.dumps(response, ensure_ascii=False))
165
+ return 0
166
+
167
+
168
+ def run_mcp_server(argv: list[str]) -> int:
169
+ """Serve vault_compute over stdio MCP."""
170
+ parser = argparse.ArgumentParser(prog="vaultcompute mcp-server", usage=USAGE)
171
+ parser.add_argument("--config", type=Path, default=Path("vaultcompute.yaml"))
172
+ args = parser.parse_args(argv)
173
+
174
+ try:
175
+ mcp_server.run(args.config)
176
+ except mcp_server.SharedVaultRequired as exc:
177
+ print(f"[vaultcompute] {exc}", file=sys.stderr)
178
+ return 1
179
+ return 0
180
+
181
+
182
+ def run_audit(argv: list[str]) -> int:
183
+ """Answer "is it actually working" from the transcript, not the screen.
184
+
185
+ Exits non-zero when a hidden value or blocked probing burst is found, so it
186
+ can gate a pipeline.
187
+ """
188
+ parser = argparse.ArgumentParser(prog="vaultcompute audit", usage=USAGE)
189
+ parser.add_argument("transcript", type=Path)
190
+ parser.add_argument("--session", default=None, help="defaults to the transcript's own")
191
+ parser.add_argument("--config", type=Path, default=Path("vaultcompute.yaml"))
192
+ args = parser.parse_args(argv)
193
+
194
+ if not args.transcript.exists():
195
+ print(f"[vaultcompute] no such transcript: {args.transcript}", file=sys.stderr)
196
+ return 2
197
+
198
+ config = load_config(args.config) if args.config.exists() else VaultComputeConfig()
199
+ store = build_token_store(config)
200
+ try:
201
+ text = audit_mod.read_transcript(args.transcript)
202
+ sessions = [args.session] if args.session else audit_mod.session_ids_in(args.transcript)
203
+ if not sessions:
204
+ print(
205
+ "[vaultcompute] the transcript names no session; pass --session",
206
+ file=sys.stderr,
207
+ )
208
+ return 2
209
+ clean = True
210
+ for session in sessions:
211
+ if len(sessions) > 1:
212
+ print(f"\n--- session {session} ---")
213
+ report = audit_mod.audit(text, store, session)
214
+ print(report.render())
215
+ clean = clean and report.passed
216
+ finally:
217
+ close = getattr(store, "close", None)
218
+ if close is not None:
219
+ close()
220
+ return 0 if clean else 1
221
+
222
+
223
+ if __name__ == "__main__":
224
+ raise SystemExit(main())