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.
- vaultcompute/__init__.py +18 -0
- vaultcompute/__main__.py +3 -0
- vaultcompute/audit.py +253 -0
- vaultcompute/cli.py +224 -0
- vaultcompute/config.py +354 -0
- vaultcompute/core/__init__.py +0 -0
- vaultcompute/core/capabilities.py +60 -0
- vaultcompute/core/compute_attempts.py +39 -0
- vaultcompute/core/lineage.py +86 -0
- vaultcompute/core/policy.py +29 -0
- vaultcompute/core/protection.py +69 -0
- vaultcompute/core/rehydrator.py +79 -0
- vaultcompute/core/sqlite_store.py +463 -0
- vaultcompute/core/table.py +178 -0
- vaultcompute/core/tokenizer.py +293 -0
- vaultcompute/core/vault.py +188 -0
- vaultcompute/errors.py +5 -0
- vaultcompute/hooks.py +102 -0
- vaultcompute/hosts/__init__.py +15 -0
- vaultcompute/hosts/claude_code.py +191 -0
- vaultcompute/hosts/codex.py +139 -0
- vaultcompute/hosts/common.py +95 -0
- vaultcompute/mcp_server.py +198 -0
- vaultcompute/ports/__init__.py +0 -0
- vaultcompute/ports/policy.py +24 -0
- vaultcompute/ports/sandbox.py +19 -0
- vaultcompute/ports/token_store.py +68 -0
- vaultcompute/proxy.py +467 -0
- vaultcompute/py.typed +0 -0
- vaultcompute/sandbox/__init__.py +0 -0
- vaultcompute/sandbox/subprocess_.py +136 -0
- vaultcompute/session.py +120 -0
- vaultcompute/tools/__init__.py +0 -0
- vaultcompute/tools/vault_compute.py +182 -0
- vaultcompute/tools/vault_table.py +163 -0
- vaultcompute-0.1.0a1.dist-info/METADATA +307 -0
- vaultcompute-0.1.0a1.dist-info/RECORD +40 -0
- vaultcompute-0.1.0a1.dist-info/WHEEL +4 -0
- vaultcompute-0.1.0a1.dist-info/entry_points.txt +2 -0
- vaultcompute-0.1.0a1.dist-info/licenses/LICENSE +21 -0
vaultcompute/__init__.py
ADDED
|
@@ -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
|
+
]
|
vaultcompute/__main__.py
ADDED
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())
|