vault-engine 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.
- vault_engine-0.1.0.dist-info/METADATA +270 -0
- vault_engine-0.1.0.dist-info/RECORD +24 -0
- vault_engine-0.1.0.dist-info/WHEEL +5 -0
- vault_engine-0.1.0.dist-info/entry_points.txt +2 -0
- vault_engine-0.1.0.dist-info/licenses/LICENSE +201 -0
- vault_engine-0.1.0.dist-info/licenses/NOTICE +6 -0
- vault_engine-0.1.0.dist-info/top_level.txt +1 -0
- vaultengine/__init__.py +28 -0
- vaultengine/__main__.py +10 -0
- vaultengine/cli.py +269 -0
- vaultengine/clipboard.py +71 -0
- vaultengine/config.py +96 -0
- vaultengine/detectors.py +88 -0
- vaultengine/formats.py +59 -0
- vaultengine/mapping.py +205 -0
- vaultengine/pipeline.py +126 -0
- vaultengine/prompts.py +103 -0
- vaultengine/providers/__init__.py +14 -0
- vaultengine/providers/base.py +164 -0
- vaultengine/providers/null.py +25 -0
- vaultengine/providers/ollama.py +67 -0
- vaultengine/providers/openai_compat.py +61 -0
- vaultengine/report.py +129 -0
- vaultengine/spans.py +117 -0
vaultengine/cli.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
vault-engine scrub INFILE [-o OUT] [--map MAP] [--report REPORT] ...
|
|
4
|
+
vault-engine rehydrate INFILE --map MAP [-o OUT]
|
|
5
|
+
vault-engine providers
|
|
6
|
+
vault-engine models
|
|
7
|
+
vault-engine version
|
|
8
|
+
|
|
9
|
+
Use ``-`` for INFILE/OUT to read stdin / write stdout.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from typing import List, Optional
|
|
19
|
+
|
|
20
|
+
from . import __version__, formats
|
|
21
|
+
from .config import POLICIES, Config
|
|
22
|
+
from .mapping import Vault
|
|
23
|
+
from .pipeline import deidentify, rehydrate
|
|
24
|
+
from .providers import available
|
|
25
|
+
from .providers.ollama import DEFAULT_ENDPOINT, installed_models
|
|
26
|
+
|
|
27
|
+
EXIT_OK = 0
|
|
28
|
+
EXIT_ERROR = 1
|
|
29
|
+
EXIT_DEGRADED = 3 # ran, but a requested protection layer did not (under-redacted)
|
|
30
|
+
|
|
31
|
+
DEFAULT_CLIP_MAP = os.path.join(os.path.expanduser("~"), ".vault-engine",
|
|
32
|
+
"clip.map.json")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _read(path: str) -> str:
|
|
36
|
+
if path == "-":
|
|
37
|
+
return sys.stdin.read()
|
|
38
|
+
with open(path, encoding="utf-8") as fh:
|
|
39
|
+
return fh.read()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _write(path: str, data: str) -> None:
|
|
43
|
+
if path == "-":
|
|
44
|
+
sys.stdout.write(data)
|
|
45
|
+
return
|
|
46
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
47
|
+
fh.write(data)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _default_out(infile: str) -> str:
|
|
51
|
+
if infile == "-":
|
|
52
|
+
return "-"
|
|
53
|
+
stem, ext = os.path.splitext(infile)
|
|
54
|
+
return f"{stem}.scrubbed{ext or '.txt'}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _config_from(args: argparse.Namespace) -> Config:
|
|
58
|
+
overrides = {
|
|
59
|
+
"provider": args.provider, "model": args.model,
|
|
60
|
+
"endpoint": args.endpoint, "api_key": args.api_key,
|
|
61
|
+
"policy": args.policy, "locale": args.locale,
|
|
62
|
+
"use_llm": (False if args.no_llm else None),
|
|
63
|
+
"critic": (False if args.no_critic else None),
|
|
64
|
+
}
|
|
65
|
+
return Config.load(path=args.config,
|
|
66
|
+
overrides={k: v for k, v in overrides.items()
|
|
67
|
+
if v is not None})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cmd_scrub(args: argparse.Namespace) -> int:
|
|
71
|
+
text = _read(args.infile)
|
|
72
|
+
config = _config_from(args)
|
|
73
|
+
segs = formats.segment(text, args.format)
|
|
74
|
+
result = deidentify(text, config, segments=segs)
|
|
75
|
+
|
|
76
|
+
out = args.out or _default_out(args.infile)
|
|
77
|
+
_write(out, result.text)
|
|
78
|
+
|
|
79
|
+
# reverse map — local only, never for the cloud
|
|
80
|
+
if not args.one_way:
|
|
81
|
+
map_path = args.map or (
|
|
82
|
+
"vault.map.json" if out == "-" else out + ".map.json")
|
|
83
|
+
if map_path != "-":
|
|
84
|
+
result.vault.save(map_path)
|
|
85
|
+
else:
|
|
86
|
+
sys.stdout.write("\n" + json.dumps(result.map, ensure_ascii=False))
|
|
87
|
+
|
|
88
|
+
if args.report:
|
|
89
|
+
_write(args.report, result.report.to_markdown())
|
|
90
|
+
|
|
91
|
+
# always tell the human what happened (on stderr so stdout stays clean)
|
|
92
|
+
print(result.report.summary(), file=sys.stderr)
|
|
93
|
+
for w in result.report.warnings:
|
|
94
|
+
print(" ⚠️ " + w, file=sys.stderr)
|
|
95
|
+
if not args.one_way and out != "-":
|
|
96
|
+
print(f" 脱敏文本 → {out}", file=sys.stderr)
|
|
97
|
+
print(f" 反向映射 → {map_path} (本地保存,切勿出云/入库)", file=sys.stderr)
|
|
98
|
+
|
|
99
|
+
if not result.safe and not args.allow_degraded:
|
|
100
|
+
print("✗ 请求的模型检测层未运行;输出可能脱敏不足。确认无碍可加 "
|
|
101
|
+
"--allow-degraded。", file=sys.stderr)
|
|
102
|
+
return EXIT_DEGRADED
|
|
103
|
+
return EXIT_OK
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cmd_rehydrate(args: argparse.Namespace) -> int:
|
|
107
|
+
payload = _read(args.infile)
|
|
108
|
+
vault = Vault.load(args.map)
|
|
109
|
+
try: # structured reply: rehydrate in place
|
|
110
|
+
obj = json.loads(payload)
|
|
111
|
+
restored = json.dumps(rehydrate(obj, vault), ensure_ascii=False, indent=2)
|
|
112
|
+
except json.JSONDecodeError: # plain text reply
|
|
113
|
+
restored = rehydrate(payload, vault)
|
|
114
|
+
_write(args.out or "-", restored)
|
|
115
|
+
if args.out and args.out != "-":
|
|
116
|
+
print(f"已回填真实身份 → {args.out}", file=sys.stderr)
|
|
117
|
+
return EXIT_OK
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def cmd_clip(args: argparse.Namespace) -> int:
|
|
121
|
+
"""Scrub (or rehydrate) the clipboard in place — the paste-into-ChatGPT hook."""
|
|
122
|
+
from . import clipboard
|
|
123
|
+
map_path = args.map or DEFAULT_CLIP_MAP
|
|
124
|
+
try:
|
|
125
|
+
text = clipboard.read_clipboard()
|
|
126
|
+
except clipboard.ClipboardError as exc:
|
|
127
|
+
print(f"✗ {exc}", file=sys.stderr)
|
|
128
|
+
return EXIT_ERROR
|
|
129
|
+
if not text.strip():
|
|
130
|
+
print("剪贴板为空。", file=sys.stderr)
|
|
131
|
+
return EXIT_ERROR
|
|
132
|
+
|
|
133
|
+
if args.rehydrate:
|
|
134
|
+
vault = Vault.load(map_path)
|
|
135
|
+
try:
|
|
136
|
+
restored = json.dumps(rehydrate(json.loads(text), vault),
|
|
137
|
+
ensure_ascii=False, indent=2)
|
|
138
|
+
except json.JSONDecodeError:
|
|
139
|
+
restored = rehydrate(text, vault)
|
|
140
|
+
clipboard.write_clipboard(restored)
|
|
141
|
+
print("✓ 剪贴板已用本地映射还原为真实身份。", file=sys.stderr)
|
|
142
|
+
return EXIT_OK
|
|
143
|
+
|
|
144
|
+
config = _config_from(args)
|
|
145
|
+
segs = formats.segment(text, args.format)
|
|
146
|
+
result = deidentify(text, config, segments=segs)
|
|
147
|
+
clipboard.write_clipboard(result.text)
|
|
148
|
+
if not args.one_way:
|
|
149
|
+
os.makedirs(os.path.dirname(map_path), exist_ok=True)
|
|
150
|
+
result.vault.save(map_path)
|
|
151
|
+
|
|
152
|
+
print(result.report.summary(), file=sys.stderr)
|
|
153
|
+
for w in result.report.warnings:
|
|
154
|
+
print(" ⚠️ " + w, file=sys.stderr)
|
|
155
|
+
print("✓ 剪贴板已脱敏,可直接粘贴给云端 AI。", file=sys.stderr)
|
|
156
|
+
if not args.one_way:
|
|
157
|
+
print(f" 反向映射 → {map_path}(本地保存,勿出云)", file=sys.stderr)
|
|
158
|
+
print(" 云端回复贴回剪贴板后,用 `vault-engine clip --rehydrate` 还原。",
|
|
159
|
+
file=sys.stderr)
|
|
160
|
+
if not result.safe and not args.allow_degraded:
|
|
161
|
+
print("✗ 模型检测层未运行,可能脱敏不足(--allow-degraded 忽略)。", file=sys.stderr)
|
|
162
|
+
return EXIT_DEGRADED
|
|
163
|
+
return EXIT_OK
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def cmd_providers(_args: argparse.Namespace) -> int:
|
|
167
|
+
print("可用 provider:")
|
|
168
|
+
notes = {"ollama": "本地 Ollama(默认,原文不出本机)",
|
|
169
|
+
"openai-compat": "OpenAI 兼容端点(⚠️ 原文会出本机,默认不用)",
|
|
170
|
+
"null": "纯正则离线(无模型,仅兜底)"}
|
|
171
|
+
for name in available():
|
|
172
|
+
print(f" - {name:14s} {notes.get(name, '')}")
|
|
173
|
+
return EXIT_OK
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def cmd_models(args: argparse.Namespace) -> int:
|
|
177
|
+
endpoint = args.endpoint or DEFAULT_ENDPOINT
|
|
178
|
+
models = installed_models(endpoint)
|
|
179
|
+
if not models:
|
|
180
|
+
print(f"未能从 {endpoint} 获取模型列表(Ollama 未运行?)", file=sys.stderr)
|
|
181
|
+
return EXIT_ERROR
|
|
182
|
+
print(f"{endpoint} 已安装模型:")
|
|
183
|
+
for m in models:
|
|
184
|
+
print(f" - {m}")
|
|
185
|
+
return EXIT_OK
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def cmd_version(_args: argparse.Namespace) -> int:
|
|
189
|
+
print(f"vault-engine {__version__}")
|
|
190
|
+
return EXIT_OK
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _add_model_flags(p: argparse.ArgumentParser) -> None:
|
|
194
|
+
p.add_argument("--provider", help="ollama(默认) / openai-compat / null")
|
|
195
|
+
p.add_argument("--model", help="模型 tag,默认 qwen3.6:27b")
|
|
196
|
+
p.add_argument("--endpoint", help="provider 端点 URL")
|
|
197
|
+
p.add_argument("--api-key", dest="api_key", help="远程端点的 API key")
|
|
198
|
+
p.add_argument("--config", help="JSON 配置文件路径(亦读 VAULT_* 环境变量)")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
202
|
+
parser = argparse.ArgumentParser(
|
|
203
|
+
prog="vault-engine",
|
|
204
|
+
description="出云前的身份脱敏:本地检测 + 一致化假名 + 可逆回路(默认本地模型)。")
|
|
205
|
+
parser.add_argument("--version", action="version",
|
|
206
|
+
version=f"vault-engine {__version__}")
|
|
207
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
208
|
+
|
|
209
|
+
s = sub.add_parser("scrub", help="脱敏一个文件/标准输入")
|
|
210
|
+
s.add_argument("infile", help="输入文件(- 表示 stdin)")
|
|
211
|
+
s.add_argument("-o", "--out", help="脱敏输出(默认 <name>.scrubbed.<ext>;- 表示 stdout)")
|
|
212
|
+
s.add_argument("--map", help="反向映射保存路径(默认 <out>.map.json)")
|
|
213
|
+
s.add_argument("--report", help="把完整风险报告写到此路径")
|
|
214
|
+
s.add_argument("--format", choices=formats.FORMATS, default=formats.AUTO,
|
|
215
|
+
help="plain / markdown(保护 ``` 代码块) / auto(默认)")
|
|
216
|
+
s.add_argument("--policy", choices=POLICIES, help="脱敏力度,默认 balanced")
|
|
217
|
+
s.add_argument("--locale", help="zh(默认) / en:正则集与 prompt 语言")
|
|
218
|
+
s.add_argument("--no-llm", action="store_true", help="仅正则、不调模型(离线兜底)")
|
|
219
|
+
s.add_argument("--no-critic", action="store_true", help="跳过残留风险复审")
|
|
220
|
+
s.add_argument("--one-way", action="store_true",
|
|
221
|
+
help="单向发布模式:不产出反向映射(不可回填)")
|
|
222
|
+
s.add_argument("--allow-degraded", action="store_true",
|
|
223
|
+
help="即便模型层未运行也以 0 退出")
|
|
224
|
+
_add_model_flags(s)
|
|
225
|
+
s.set_defaults(func=cmd_scrub)
|
|
226
|
+
|
|
227
|
+
r = sub.add_parser("rehydrate", help="用映射把云端回复里的代号还原成真实身份")
|
|
228
|
+
r.add_argument("infile", help="云端回复(- 表示 stdin)")
|
|
229
|
+
r.add_argument("--map", required=True, help="scrub 时产出的 *.map.json")
|
|
230
|
+
r.add_argument("-o", "--out", help="输出路径(默认 stdout)")
|
|
231
|
+
r.set_defaults(func=cmd_rehydrate)
|
|
232
|
+
|
|
233
|
+
c = sub.add_parser("clip", help="脱敏剪贴板内容(贴进 ChatGPT 前一键洗)")
|
|
234
|
+
c.add_argument("--rehydrate", action="store_true",
|
|
235
|
+
help="反向:用映射把剪贴板里的代号还原成真实身份")
|
|
236
|
+
c.add_argument("--map", help=f"映射路径(默认 {DEFAULT_CLIP_MAP})")
|
|
237
|
+
c.add_argument("--format", choices=formats.FORMATS, default=formats.AUTO)
|
|
238
|
+
c.add_argument("--policy", choices=POLICIES, help="脱敏力度,默认 balanced")
|
|
239
|
+
c.add_argument("--locale", help="zh(默认) / en")
|
|
240
|
+
c.add_argument("--no-llm", action="store_true", help="仅正则、不调模型")
|
|
241
|
+
c.add_argument("--no-critic", action="store_true", help="跳过残留风险复审")
|
|
242
|
+
c.add_argument("--one-way", action="store_true", help="不产出反向映射")
|
|
243
|
+
c.add_argument("--allow-degraded", action="store_true",
|
|
244
|
+
help="即便模型层未运行也以 0 退出")
|
|
245
|
+
_add_model_flags(c)
|
|
246
|
+
c.set_defaults(func=cmd_clip)
|
|
247
|
+
|
|
248
|
+
sub.add_parser("providers", help="列出可用大模型 provider").set_defaults(
|
|
249
|
+
func=cmd_providers)
|
|
250
|
+
|
|
251
|
+
m = sub.add_parser("models", help="列出本地 Ollama 已安装模型")
|
|
252
|
+
m.add_argument("--endpoint", help="Ollama 端点,默认 http://localhost:11434")
|
|
253
|
+
m.set_defaults(func=cmd_models)
|
|
254
|
+
|
|
255
|
+
sub.add_parser("version", help="打印版本").set_defaults(func=cmd_version)
|
|
256
|
+
return parser
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
260
|
+
args = build_parser().parse_args(argv)
|
|
261
|
+
try:
|
|
262
|
+
return args.func(args)
|
|
263
|
+
except (OSError, ValueError) as exc:
|
|
264
|
+
print(f"✗ {exc}", file=sys.stderr)
|
|
265
|
+
return EXIT_ERROR
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
if __name__ == "__main__":
|
|
269
|
+
sys.exit(main())
|
vaultengine/clipboard.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Cross-platform clipboard access through OS tools — stdlib subprocess, no deps.
|
|
2
|
+
|
|
3
|
+
macOS uses pbcopy/pbpaste; Windows uses PowerShell; Linux uses wl-clipboard,
|
|
4
|
+
xclip, or xsel (whichever is installed). Raises :class:`ClipboardError` with an
|
|
5
|
+
actionable message when no tool is available.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
_TIMEOUT = 10
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ClipboardError(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _candidates():
|
|
22
|
+
"""Return (paste_commands, copy_commands) for the current platform."""
|
|
23
|
+
if sys.platform == "darwin":
|
|
24
|
+
return [["pbpaste"]], [["pbcopy"]]
|
|
25
|
+
if sys.platform == "win32":
|
|
26
|
+
return ([["powershell", "-noprofile", "-command", "Get-Clipboard"]],
|
|
27
|
+
[["powershell", "-noprofile", "-command", "$input | Set-Clipboard"]])
|
|
28
|
+
paste, copy = [], []
|
|
29
|
+
if shutil.which("wl-paste"):
|
|
30
|
+
paste.append(["wl-paste", "--no-newline"])
|
|
31
|
+
if shutil.which("wl-copy"):
|
|
32
|
+
copy.append(["wl-copy"])
|
|
33
|
+
if shutil.which("xclip"):
|
|
34
|
+
paste.append(["xclip", "-selection", "clipboard", "-o"])
|
|
35
|
+
copy.append(["xclip", "-selection", "clipboard", "-i"])
|
|
36
|
+
if shutil.which("xsel"):
|
|
37
|
+
paste.append(["xsel", "--clipboard", "--output"])
|
|
38
|
+
copy.append(["xsel", "--clipboard", "--input"])
|
|
39
|
+
return paste, copy
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _pick(cmds):
|
|
43
|
+
for cmd in cmds:
|
|
44
|
+
if sys.platform == "win32" or shutil.which(cmd[0]):
|
|
45
|
+
return cmd
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read_clipboard() -> str:
|
|
50
|
+
cmd = _pick(_candidates()[0])
|
|
51
|
+
if not cmd:
|
|
52
|
+
raise ClipboardError(
|
|
53
|
+
"找不到剪贴板读取工具(macOS: pbpaste;Linux: 装 xclip / xsel / wl-clipboard)")
|
|
54
|
+
try:
|
|
55
|
+
proc = subprocess.run(cmd, capture_output=True, encoding="utf-8",
|
|
56
|
+
timeout=_TIMEOUT)
|
|
57
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
58
|
+
raise ClipboardError(f"读取剪贴板失败:{exc}") from exc
|
|
59
|
+
return proc.stdout or ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def write_clipboard(text: str) -> None:
|
|
63
|
+
cmd = _pick(_candidates()[1])
|
|
64
|
+
if not cmd:
|
|
65
|
+
raise ClipboardError(
|
|
66
|
+
"找不到剪贴板写入工具(macOS: pbcopy;Linux: 装 xclip / xsel / wl-clipboard)")
|
|
67
|
+
try:
|
|
68
|
+
subprocess.run(cmd, input=text, encoding="utf-8", timeout=_TIMEOUT,
|
|
69
|
+
check=True)
|
|
70
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
71
|
+
raise ClipboardError(f"写入剪贴板失败:{exc}") from exc
|
vaultengine/config.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Runtime configuration.
|
|
2
|
+
|
|
3
|
+
Resolution order (later wins):
|
|
4
|
+
dataclass defaults < JSON config file < VAULT_* environment < CLI flags
|
|
5
|
+
|
|
6
|
+
Everything is plain stdlib so the package stays dependency-free.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import asdict, dataclass, fields
|
|
14
|
+
from typing import Any, Dict, Optional
|
|
15
|
+
|
|
16
|
+
# Redaction policies — see README "脱敏力度 / aggressiveness".
|
|
17
|
+
POLICY_BALANCED = "balanced" # typed reversible tokens; coarse structure kept
|
|
18
|
+
POLICY_MAX = "max" # opaque tokens, dates coarsened — type hidden too
|
|
19
|
+
POLICY_LIGHT = "light" # only persons + explicit PII; context untouched
|
|
20
|
+
POLICIES = (POLICY_BALANCED, POLICY_MAX, POLICY_LIGHT)
|
|
21
|
+
|
|
22
|
+
DEFAULT_MODEL = "qwen3.6:27b" # the de-identification model, runtime-swappable
|
|
23
|
+
DEFAULT_PROVIDER = "ollama"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Config:
|
|
28
|
+
provider: str = DEFAULT_PROVIDER
|
|
29
|
+
model: str = DEFAULT_MODEL
|
|
30
|
+
endpoint: str = "" # provider default used when empty
|
|
31
|
+
api_key: str = "" # only for remote (openai-compat) providers
|
|
32
|
+
policy: str = POLICY_BALANCED
|
|
33
|
+
locale: str = "zh" # selects regex sets / prompt language
|
|
34
|
+
|
|
35
|
+
use_llm: bool = True # False => deterministic detectors only (offline)
|
|
36
|
+
critic: bool = True # residual-risk second LLM pass
|
|
37
|
+
|
|
38
|
+
num_ctx: int = 8192 # context window hint passed to local models
|
|
39
|
+
timeout: int = 300 # seconds per model call
|
|
40
|
+
chunk_chars: int = 6000 # long inputs are split for detection
|
|
41
|
+
chunk_overlap: int = 400 # overlap so entities on a boundary aren't lost
|
|
42
|
+
|
|
43
|
+
def validate(self) -> "Config":
|
|
44
|
+
if self.policy not in POLICIES:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"未知 policy {self.policy!r}(可选 {POLICIES})")
|
|
47
|
+
if self.chunk_overlap >= self.chunk_chars:
|
|
48
|
+
raise ValueError("chunk_overlap 必须小于 chunk_chars")
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
52
|
+
return asdict(self)
|
|
53
|
+
|
|
54
|
+
# -- layered construction ------------------------------------------------
|
|
55
|
+
@classmethod
|
|
56
|
+
def _field_names(cls) -> set:
|
|
57
|
+
return {f.name for f in fields(cls)}
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def load(cls, path: Optional[str] = None,
|
|
61
|
+
overrides: Optional[Dict[str, Any]] = None) -> "Config":
|
|
62
|
+
"""Build a Config from (optional) file + environment + overrides."""
|
|
63
|
+
data: Dict[str, Any] = {}
|
|
64
|
+
|
|
65
|
+
path = path or os.environ.get("VAULT_CONFIG")
|
|
66
|
+
if path and os.path.isfile(path):
|
|
67
|
+
with open(path, encoding="utf-8") as fh:
|
|
68
|
+
file_data = json.load(fh)
|
|
69
|
+
data.update({k: v for k, v in file_data.items()
|
|
70
|
+
if k in cls._field_names()})
|
|
71
|
+
|
|
72
|
+
data.update(cls._from_env())
|
|
73
|
+
|
|
74
|
+
if overrides:
|
|
75
|
+
data.update({k: v for k, v in overrides.items()
|
|
76
|
+
if k in cls._field_names() and v is not None})
|
|
77
|
+
|
|
78
|
+
return cls(**data).validate()
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def _from_env(cls) -> Dict[str, Any]:
|
|
82
|
+
names = cls._field_names()
|
|
83
|
+
bool_fields = {"use_llm", "critic"}
|
|
84
|
+
int_fields = {"num_ctx", "timeout", "chunk_chars", "chunk_overlap"}
|
|
85
|
+
out: Dict[str, Any] = {}
|
|
86
|
+
for name in names:
|
|
87
|
+
raw = os.environ.get("VAULT_" + name.upper())
|
|
88
|
+
if raw is None:
|
|
89
|
+
continue
|
|
90
|
+
if name in bool_fields:
|
|
91
|
+
out[name] = raw.strip().lower() in ("1", "true", "yes", "on")
|
|
92
|
+
elif name in int_fields:
|
|
93
|
+
out[name] = int(raw)
|
|
94
|
+
else:
|
|
95
|
+
out[name] = raw
|
|
96
|
+
return out
|
vaultengine/detectors.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Deterministic detectors — the always-on, offline, no-LLM layer.
|
|
2
|
+
|
|
3
|
+
These catch structured PII that an LLM may paraphrase past or silently miss:
|
|
4
|
+
emails, phone numbers, URLs, handles, IP/MAC, government IDs, payment cards
|
|
5
|
+
(Luhn-checked), crypto addresses. They run with or without a model and give the
|
|
6
|
+
pipeline a reliable floor of coverage.
|
|
7
|
+
|
|
8
|
+
Every detector returns offset-anchored :class:`~vaultengine.spans.Span` objects,
|
|
9
|
+
so overlaps are resolved deterministically by ``spans.merge_overlapping``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from typing import List
|
|
16
|
+
|
|
17
|
+
from .spans import (CAT_CONTACT, CAT_ID, Span)
|
|
18
|
+
|
|
19
|
+
# (sub-kind, category, compiled pattern). Order is informational only — overlap
|
|
20
|
+
# resolution is by offset/length, not declaration order.
|
|
21
|
+
_PATTERNS = [
|
|
22
|
+
("email", CAT_CONTACT,
|
|
23
|
+
re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")),
|
|
24
|
+
("url", CAT_CONTACT,
|
|
25
|
+
re.compile(r"(?:https?://|www\.)[^\s<>()\[\]{} ,。]+",
|
|
26
|
+
re.IGNORECASE)),
|
|
27
|
+
("cn-mobile", CAT_CONTACT, re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")),
|
|
28
|
+
("phone-e164", CAT_CONTACT,
|
|
29
|
+
re.compile(r"(?<![\w+])\+\d{1,3}[\s\-]?\d[\d\s\-]{6,}\d(?!\d)")),
|
|
30
|
+
("handle", CAT_CONTACT, re.compile(r"(?<![\w@./])@[A-Za-z0-9_]{2,30}\b")),
|
|
31
|
+
("ipv4", CAT_ID,
|
|
32
|
+
re.compile(r"(?<![\d.])(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}"
|
|
33
|
+
r"(?:25[0-5]|2[0-4]\d|1?\d?\d)(?![\d.])")),
|
|
34
|
+
("ipv6", CAT_ID,
|
|
35
|
+
re.compile(r"(?<![:\w])(?:[A-Fa-f0-9]{1,4}:){2,7}[A-Fa-f0-9]{1,4}(?![:\w])")),
|
|
36
|
+
("mac", CAT_ID,
|
|
37
|
+
re.compile(r"(?<![\w:])(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}(?![\w:])")),
|
|
38
|
+
("cn-id", CAT_ID, re.compile(r"(?<!\d)\d{17}[\dXx](?!\d)")),
|
|
39
|
+
("crypto-eth", CAT_ID, re.compile(r"\b0x[a-fA-F0-9]{40}\b")),
|
|
40
|
+
("crypto-btc", CAT_ID,
|
|
41
|
+
re.compile(r"\b(?:bc1[a-z0-9]{25,39}|[13][a-km-zA-HJ-NP-Z1-9]{25,34})\b")),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
# Payment cards need a checksum, so they get a dedicated pass.
|
|
45
|
+
_CARD_CANDIDATE = re.compile(r"(?<![\d\-])(?:\d[ \-]?){13,19}(?![\d\-])")
|
|
46
|
+
# Bare long digit runs (account-ish). Low confidence: only tokenized when the
|
|
47
|
+
# policy already redacts the `id` category.
|
|
48
|
+
_LONG_DIGITS = re.compile(r"(?<!\d)\d{9,}(?!\d)")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _luhn_ok(digits: str) -> bool:
|
|
52
|
+
total, alt = 0, False
|
|
53
|
+
for ch in reversed(digits):
|
|
54
|
+
d = ord(ch) - 48
|
|
55
|
+
if alt:
|
|
56
|
+
d *= 2
|
|
57
|
+
if d > 9:
|
|
58
|
+
d -= 9
|
|
59
|
+
total += d
|
|
60
|
+
alt = not alt
|
|
61
|
+
return total % 10 == 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def detect(text: str, locale: str = "zh") -> List[Span]:
|
|
65
|
+
"""Run every deterministic detector over ``text``; return anchored Spans."""
|
|
66
|
+
if not text:
|
|
67
|
+
return []
|
|
68
|
+
spans: List[Span] = []
|
|
69
|
+
|
|
70
|
+
for kind, category, pattern in _PATTERNS:
|
|
71
|
+
for m in pattern.finditer(text):
|
|
72
|
+
spans.append(Span(surface=m.group(0), category=category,
|
|
73
|
+
source=f"regex:{kind}", confidence=1.0,
|
|
74
|
+
start=m.start(), end=m.end(), note=kind))
|
|
75
|
+
|
|
76
|
+
for m in _CARD_CANDIDATE.finditer(text):
|
|
77
|
+
digits = re.sub(r"[ \-]", "", m.group(0))
|
|
78
|
+
if 13 <= len(digits) <= 19 and _luhn_ok(digits):
|
|
79
|
+
spans.append(Span(surface=m.group(0), category=CAT_ID,
|
|
80
|
+
source="regex:card", confidence=1.0,
|
|
81
|
+
start=m.start(), end=m.end(), note="card"))
|
|
82
|
+
|
|
83
|
+
for m in _LONG_DIGITS.finditer(text):
|
|
84
|
+
spans.append(Span(surface=m.group(0), category=CAT_ID,
|
|
85
|
+
source="regex:digits", confidence=0.5,
|
|
86
|
+
start=m.start(), end=m.end(), note="digits"))
|
|
87
|
+
|
|
88
|
+
return spans
|
vaultengine/formats.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Input formats — decide which regions of a document get scrubbed.
|
|
2
|
+
|
|
3
|
+
`plain` scrubs everything. `markdown` scrubs the prose but leaves fenced
|
|
4
|
+
``` code blocks untouched — so an embedded JSON schema, code sample, or output
|
|
5
|
+
template you include for the model is preserved verbatim instead of being
|
|
6
|
+
mangled by pseudonymization.
|
|
7
|
+
|
|
8
|
+
A "segment plan" is a list of ``(kind, content)`` where kind is ``'keep'`` or
|
|
9
|
+
``'scrub'``; the concatenation of all contents always reproduces the input
|
|
10
|
+
exactly.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import List, Tuple
|
|
16
|
+
|
|
17
|
+
Segment = Tuple[str, str]
|
|
18
|
+
|
|
19
|
+
PLAIN = "plain"
|
|
20
|
+
MARKDOWN = "markdown"
|
|
21
|
+
AUTO = "auto"
|
|
22
|
+
FORMATS = (PLAIN, MARKDOWN, AUTO)
|
|
23
|
+
|
|
24
|
+
_FENCE = "```"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def detect_format(text: str) -> str:
|
|
28
|
+
"""A fenced code block is the signal to switch on `markdown` protection."""
|
|
29
|
+
return MARKDOWN if (text and _FENCE in text) else PLAIN
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _split_fences(s: str) -> List[Segment]:
|
|
33
|
+
"""Keep fenced ``` blocks verbatim; everything else is scrubbable."""
|
|
34
|
+
out: List[Segment] = []
|
|
35
|
+
i = 0
|
|
36
|
+
while True:
|
|
37
|
+
start = s.find(_FENCE, i)
|
|
38
|
+
if start == -1:
|
|
39
|
+
if s[i:]:
|
|
40
|
+
out.append(("scrub", s[i:]))
|
|
41
|
+
break
|
|
42
|
+
if s[i:start]:
|
|
43
|
+
out.append(("scrub", s[i:start]))
|
|
44
|
+
end = s.find(_FENCE, start + 3)
|
|
45
|
+
if end == -1: # unterminated fence: keep the rest
|
|
46
|
+
out.append(("keep", s[start:]))
|
|
47
|
+
break
|
|
48
|
+
end += 3
|
|
49
|
+
out.append(("keep", s[start:end]))
|
|
50
|
+
i = end
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def segment(text: str, fmt: str = AUTO) -> List[Segment]:
|
|
55
|
+
if fmt == AUTO:
|
|
56
|
+
fmt = detect_format(text)
|
|
57
|
+
if fmt == MARKDOWN:
|
|
58
|
+
return _split_fences(text)
|
|
59
|
+
return [("scrub", text)]
|