spendshield 0.6.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.
@@ -0,0 +1,32 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ SpendShield — AI Agent 付款安全层
4
+
5
+ 给 AI Agent 的「花钱动作」加上四道闸门:
6
+ 1. dry_run 干跑模式(默认开): 只预览, 不真花
7
+ 2. budget 预算上限: 超了直接拒绝
8
+ 3. approval 人工确认: 花钱前必须人点头
9
+ 4. audit 全量审计: 每次尝试都留痕
10
+
11
+ 血泪背景: 2026-08-09, 我让自动化系统测试下单, 因为 dry 参数没生效,
12
+ 4 单 99 元真实出码扣款。这个库就是那次事故的产物——
13
+ AI 时代, 别让 Agent 替你花钱之前没有闸门。
14
+
15
+ 用法:
16
+ from spendshield import SpendShield
17
+
18
+ guard = SpendShield(budget=100, dry_run=True, approval="console")
19
+
20
+ @guard.protect("下单", max_amount=50)
21
+ def place_order(order_id, amount, to):
22
+ # ... 真实下单逻辑
23
+ return {"ok": True}
24
+ """
25
+ from .guard import SpendShield, GuardedError, BudgetExceeded, NeedsApproval, DryRunBlocked, UnknownAgent, AuditRecord
26
+ from .vault import KeyVault
27
+
28
+ __version__ = "0.6.0"
29
+ __all__ = [
30
+ "SpendShield", "GuardedError", "BudgetExceeded",
31
+ "NeedsApproval", "DryRunBlocked", "UnknownAgent", "AuditRecord", "KeyVault",
32
+ ]
@@ -0,0 +1,119 @@
1
+ # -*- coding: utf-8 -*-
2
+ """SpendShield 审计 Dashboard — 可视化审计记录
3
+
4
+ 用法:
5
+ 1. 程序里导出审计: guard.export_audit("audit.json")
6
+ 2. 启动看板: python -m spendshield.dashboard --file audit.json --port 8775
7
+ 3. 打开 http://localhost:8775
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import sys
15
+ import time
16
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
17
+ from urllib.parse import urlparse
18
+
19
+ PAGE = """<!DOCTYPE html>
20
+ <html lang="zh-CN"><head><meta charset="utf-8">
21
+ <meta name="viewport" content="width=device-width,initial-scale=1">
22
+ <title>SpendShield 审计 Dashboard</title>
23
+ <style>
24
+ *{margin:0;padding:0;box-sizing:border-box}
25
+ body{font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;background:radial-gradient(900px 500px at 20% -10%,#1b1633 0%,#0b0e14 55%);color:#e6e8ee;padding:28px;min-height:100vh}
26
+ h1{font-size:20px;font-weight:800;margin-bottom:4px}h1 span{color:#a78bfa}
27
+ .sub{color:#7d8590;font-size:13px;margin-bottom:20px}
28
+ .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:20px}
29
+ .stat{background:#141b26;border:1px solid #21262d;border-radius:10px;padding:12px 14px}
30
+ .stat .v{font-size:24px;font-weight:800;line-height:1.2}
31
+ .stat .k{font-size:12px;color:#7d8590;margin-top:2px}
32
+ .stat .v.green{color:#3fb950}.stat .v.red{color:#f85149}.stat .v.purple{color:#a78bfa}.stat .v.yellow{color:#d29922}
33
+ table{width:100%;border-collapse:collapse;font-size:13px}
34
+ th,td{padding:9px 12px;text-align:left;border-bottom:1px solid #21262d}
35
+ th{color:#7d8590;font-weight:600;font-size:12px}
36
+ .blocked{color:#f85149}.executed{color:#3fb950}.dry{color:#d29922}
37
+ </style></head><body>
38
+ <h1>💰 SpendShield <span>审计 Dashboard</span></h1>
39
+ <div class="sub" id="meta">加载中...</div>
40
+ <div class="stats" id="stats"></div>
41
+ <table id="tbl"><thead><tr>
42
+ <th>时间</th><th>操作</th><th>金额</th><th>收款方</th><th>决策</th><th>原因</th><th>累计已花</th>
43
+ </tr></thead><tbody></tbody></table>
44
+ <script>
45
+ async function load(){
46
+ const r=await fetch('/api/audit');const d=await r.json();
47
+ const recs=d.records||[];
48
+ document.getElementById('meta').textContent=`${recs.length} 条记录 · 更新于 ${new Date().toLocaleTimeString()} · 30s 自动刷新`;
49
+ const executed=recs.filter(x=>x.decision==='executed');
50
+ const blocked=recs.filter(x=>x.decision.startsWith('blocked')||x.decision==='dry_run');
51
+ const spent=executed.reduce((s,x)=>s+(x.amount||0),0);
52
+ const maxAmt=Math.max(...recs.map(x=>x.amount||0),0);
53
+ document.getElementById('stats').innerHTML=
54
+ `<div class="stat"><div class="v purple">${recs.length}</div><div class="k">总尝试</div></div>
55
+ <div class="stat"><div class="v green">${executed.length}</div><div class="k">已放行</div></div>
56
+ <div class="stat"><div class="v red">${blocked.length}</div><div class="k">被拦截</div></div>
57
+ <div class="stat"><div class="v">¥${spent.toFixed(2)}</div><div class="k">累计已花</div></div>
58
+ <div class="stat"><div class="v yellow">¥${maxAmt.toFixed(2)}</div><div class="k">最大单笔</div></div>`;
59
+ const tb=document.querySelector('#tbl tbody');tb.innerHTML='';
60
+ for(const r of recs){
61
+ const cls=r.decision==='executed'?'executed':(r.decision==='dry_run'?'dry':'blocked');
62
+ const t=new Date(r.ts*1000).toLocaleString('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'});
63
+ tb.insertAdjacentHTML('beforeend',
64
+ `<tr><td>${t}</td><td>${r.action}</td><td>¥${(r.amount||0).toFixed(2)}</td><td>${r.to}</td>
65
+ <td class="${cls}">${r.decision}</td><td style="color:#9da7b3">${r.reason||''}</td><td>¥${(r.spent_after||0).toFixed(2)}</td></tr>`);
66
+ }
67
+ }
68
+ load();setInterval(load,30000);
69
+ </script></body></html>
70
+ """
71
+
72
+
73
+ class Handler(BaseHTTPRequestHandler):
74
+ file = "audit.json"
75
+
76
+ def _json(self, obj):
77
+ body = json.dumps(obj, ensure_ascii=False).encode()
78
+ self.send_response(200)
79
+ self.send_header("Content-Type", "application/json; charset=utf-8")
80
+ self.send_header("Content-Length", str(len(body)))
81
+ self.end_headers()
82
+ self.wfile.write(body)
83
+
84
+ def do_GET(self):
85
+ u = urlparse(self.path)
86
+ if u.path in ("/", "/index.html"):
87
+ body = PAGE.encode()
88
+ self.send_response(200)
89
+ self.send_header("Content-Type", "text/html; charset=utf-8")
90
+ self.send_header("Content-Length", str(len(body)))
91
+ self.end_headers()
92
+ self.wfile.write(body)
93
+ elif u.path == "/api/audit":
94
+ try:
95
+ with open(self.file, encoding="utf-8") as f:
96
+ records = json.load(f)
97
+ except Exception:
98
+ records = []
99
+ self._json({"records": records})
100
+ else:
101
+ self._json({"error": "not found"})
102
+
103
+ def log_message(self, *a):
104
+ pass
105
+
106
+
107
+ def main():
108
+ ap = argparse.ArgumentParser(prog="spendshield-dashboard")
109
+ ap.add_argument("--file", default="audit.json", help="审计 JSON 文件")
110
+ ap.add_argument("--port", type=int, default=8775)
111
+ args = ap.parse_args()
112
+ Handler.file = args.file
113
+ srv = ThreadingHTTPServer(("0.0.0.0", args.port), Handler)
114
+ print(f"SpendShield 审计看板: http://localhost:{args.port} (file={args.file})")
115
+ srv.serve_forever()
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()
spendshield/guard.py ADDED
@@ -0,0 +1,503 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ SpendShield 核心: 四道闸门实现
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import functools
8
+ import inspect
9
+ import json
10
+ import os
11
+ import time
12
+ import uuid
13
+ from dataclasses import dataclass, field, asdict
14
+ from typing import Any, Callable, Optional
15
+
16
+
17
+ class GuardedError(Exception):
18
+ """SpendShield 拦截的基础异常"""
19
+
20
+
21
+ class DryRunBlocked(GuardedError):
22
+ """干跑模式: 动作被预览拦截, 未执行"""
23
+
24
+
25
+ class BudgetExceeded(GuardedError):
26
+ """预算超限: 本次花费会导致总预算超支"""
27
+
28
+
29
+ class NeedsApproval(GuardedError):
30
+ """需要人工确认: 未获批准, 动作未执行"""
31
+
32
+
33
+ class UnknownAgent(GuardedError):
34
+ """未注册的 Agent 身份: 默认拒绝(安全默认)"""
35
+
36
+
37
+ @dataclass
38
+ class AuditRecord:
39
+ """一次被闸门处理的记录"""
40
+ id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
41
+ ts: float = field(default_factory=time.time)
42
+ action: str = ""
43
+ amount: float = 0.0
44
+ to: str = ""
45
+ agent: str = "" # 调用方 Agent 身份(KYA 最小实现)
46
+ decision: str = "" # preview / blocked_budget / blocked_approval / executed / dry_run
47
+ reason: str = ""
48
+ spent_after: float = 0.0
49
+
50
+ def to_dict(self) -> dict:
51
+ return asdict(self)
52
+
53
+
54
+ class SpendShield:
55
+ """
56
+ 给花钱函数加闸门。
57
+
58
+ 参数:
59
+ budget: 总预算(0 = 不限)
60
+ dry_run: 干跑模式, 默认 True。只预览不执行。
61
+ approval: 人工确认模式。None=不需要, "console"=终端输入, 或 callable(record)->bool
62
+ on_block: 拦截回调, 可选
63
+ log: 审计日志回调, 可选(默认打印)
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ budget: float = 0.0,
69
+ dry_run: bool = True,
70
+ approval: Optional[Any] = None,
71
+ on_block: Optional[Callable[[AuditRecord], None]] = None,
72
+ log: Optional[Callable[[AuditRecord], None]] = None,
73
+ blacklist: Optional[list] = None, # 收款方黑名单: 直接拒绝
74
+ whitelist: Optional[list] = None, # 收款方白名单: 跳过人工确认
75
+ rate_limit: Optional[dict] = None, # {"window_s": 60, "max_calls": 3} 频率限制
76
+ policy: Optional[str] = None, # 策略文件路径(spendshield.yaml)
77
+ tg_token: str = "", # 远程审批: TG bot token
78
+ tg_chat: str = "", # 远程审批: TG chat id
79
+ webhook_url: str = "", # 远程审批: Webhook URL
80
+ agents: Optional[dict] = None, # Agent 身份层: {agent_id: {budget/max_amount/blacklist/...}}
81
+ allow_unknown: bool = False, # 未注册 agent 是否回落全局策略(安全默认拒绝)
82
+ approve_new_recipient: bool = True, # 意图一致性: 新收款方首次交易强制审批(防提示注入); 未配置审批通道则默认拒绝
83
+ approve_above: float = 0.0, # 意图一致性: 超过该金额的转账强制审批(0 = 不限)
84
+ key_vault: Optional[Any] = None, # 密钥保险库(KeyVault 实例): get_secret 过闸门才能取
85
+ ):
86
+ self.budget = budget
87
+ self.dry_run = dry_run
88
+ self.approval = approval
89
+ self.on_block = on_block
90
+ self.log = log or (lambda rec: print(f"[SpendShield] {rec.decision}: {rec.action} ¥{rec.amount} -> {rec.to}"))
91
+ self.blacklist = [str(x).lower() for x in (blacklist or [])]
92
+ self.whitelist = [str(x).lower() for x in (whitelist or [])]
93
+ self.rate_limit = rate_limit or {}
94
+ self.tg_token = tg_token
95
+ self.tg_chat = tg_chat
96
+ self.webhook_url = webhook_url
97
+ self._tg_offset = 0
98
+ self.default_max_amount = 0.0
99
+ self._rate_hits: list[tuple] = [] # (ts, agent, to)
100
+ self._spent = 0.0
101
+ self.records: list[AuditRecord] = []
102
+ self._agents: dict[str, dict] = {}
103
+ self._agent_spent: dict[str, float] = {}
104
+ self.allow_unknown = allow_unknown
105
+ self.approve_new_recipient = approve_new_recipient
106
+ self.approve_above = approve_above
107
+ self.vault = key_vault
108
+ self._known_recipients: set[str] = set() # 成功交易过的收款方(意图一致性记忆)
109
+ for aid, aconf in (agents or {}).items():
110
+ self.register_agent(aid, **{k: v for k, v in aconf.items()
111
+ if k in ("budget", "max_amount", "blacklist",
112
+ "whitelist", "rate_limit", "approval")})
113
+ if policy:
114
+ self.load_policy(policy)
115
+
116
+ @property
117
+ def spent(self) -> float:
118
+ return self._spent
119
+
120
+ def _record(self, **kw) -> AuditRecord:
121
+ rec = AuditRecord(**kw)
122
+ self.records.append(rec)
123
+ return rec
124
+
125
+ def _check(self, action: str, amount: float, to: str, agent: str = "") -> AuditRecord:
126
+ """四道闸门, 返回通过的记录(未执行), 抛异常则被拦。
127
+ agent: 调用方身份(Agent ID, KYA 最小实现)。未注册默认拒绝, allow_unknown=True 回落全局策略。"""
128
+ try:
129
+ ap = self._agent_policy(agent)
130
+ except UnknownAgent:
131
+ rec = self._record(action=action, amount=amount, to=to, agent=agent,
132
+ decision="blocked_unknown_agent", reason="未注册的 Agent 身份, 默认拒绝",
133
+ spent_after=self._spent)
134
+ self.log(rec)
135
+ if self.on_block:
136
+ self.on_block(rec)
137
+ raise
138
+ # 合并生效策略: agent 级覆盖全局
139
+ bl = self.blacklist + list(ap.get("blacklist", []))
140
+ wl = self.whitelist + list(ap.get("whitelist", []))
141
+ rl = ap.get("rate_limit") or self.rate_limit
142
+ ab = float(ap.get("budget", 0) or 0)
143
+ appr = ap.get("approval") if "approval" in ap else self.approval
144
+ agent_spent = self._agent_spent.get(agent, 0.0) if agent else self._spent
145
+
146
+ # 1. dry_run 干跑
147
+ if self.dry_run:
148
+ rec = self._record(action=action, amount=amount, to=to, agent=agent,
149
+ decision="dry_run", reason="dry_run=True 干跑模式, 未执行",
150
+ spent_after=self._spent)
151
+ self.log(rec)
152
+ raise DryRunBlocked(f"[干跑] {action} ¥{amount} -> {to} (未执行, 关掉 dry_run 才会真花)")
153
+
154
+ # 1.5 黑名单: 直接拒绝
155
+ to_l = str(to).lower()
156
+ if any(b in to_l for b in bl):
157
+ rec = self._record(action=action, amount=amount, to=to, agent=agent,
158
+ decision="blocked_blacklist", reason=f"收款方在黑名单: {to}",
159
+ spent_after=self._spent)
160
+ self.log(rec)
161
+ if self.on_block:
162
+ self.on_block(rec)
163
+ raise BudgetExceeded(f"[黑名单] {action} ¥{amount} -> {to} 被拒绝(黑名单收款方)")
164
+
165
+ # 1.6 频率限制: 同一 agent+收款方 window 内 max_calls 次
166
+ if rl:
167
+ now = time.time()
168
+ window = rl.get("window_s", 60)
169
+ max_calls = rl.get("max_calls", 3)
170
+ self._rate_hits = [(t, a, r) for t, a, r in self._rate_hits if now - t < window]
171
+ hits = sum(1 for _, a, r in self._rate_hits if r == to_l and a == agent)
172
+ if hits >= max_calls:
173
+ rec = self._record(action=action, amount=amount, to=to, agent=agent,
174
+ decision="blocked_rate", reason=f"收款方 {to} {window}s 内超过 {max_calls} 次",
175
+ spent_after=self._spent)
176
+ self.log(rec)
177
+ if self.on_block:
178
+ self.on_block(rec)
179
+ raise BudgetExceeded(f"[频率] {action} ¥{amount} -> {to} 触发频率限制({window}s/{max_calls}次)")
180
+ self._rate_hits.append((now, agent, to_l))
181
+
182
+ # 2. 预算: agent 级分闸优先, 全局总闸兜底
183
+ if ab > 0 and agent_spent + amount > ab:
184
+ rec = self._record(action=action, amount=amount, to=to, agent=agent,
185
+ decision="blocked_budget",
186
+ reason=f"Agent[{agent}] 已花 ¥{agent_spent:.2f} + ¥{amount:.2f} > 预算 ¥{ab:.2f}",
187
+ spent_after=self._spent)
188
+ self.log(rec)
189
+ if self.on_block:
190
+ self.on_block(rec)
191
+ raise BudgetExceeded(f"[预算] {action} ¥{amount} 超支: Agent[{agent}] 已花 ¥{agent_spent:.2f}, 预算 ¥{ab:.2f}")
192
+ if self.budget > 0 and self._spent + amount > self.budget:
193
+ rec = self._record(action=action, amount=amount, to=to, agent=agent,
194
+ decision="blocked_budget",
195
+ reason=f"已花 ¥{self._spent:.2f} + ¥{amount:.2f} > 总预算 ¥{self.budget:.2f}",
196
+ spent_after=self._spent)
197
+ self.log(rec)
198
+ if self.on_block:
199
+ self.on_block(rec)
200
+ raise BudgetExceeded(f"[预算] {action} ¥{amount} 超支: 已花 ¥{self._spent:.2f}, 总预算 ¥{self.budget:.2f}")
201
+
202
+ # 2.5/3 审批: 白名单跳过; 否则:
203
+ # - 意图一致性(防提示注入): 敏感操作(新收款方/大额)强制审批; 未配置审批通道 → 直接拒绝(安全默认)
204
+ # - 全局 approval 配置: 每笔都问(强模式)
205
+ if not any(w in to_l for w in wl):
206
+ sensitive = False
207
+ sensitive_reason = ""
208
+ if self.approve_new_recipient and to_l not in self._known_recipients:
209
+ sensitive, sensitive_reason = True, f"新收款方需确认: {to}"
210
+ elif self.approve_above > 0 and amount > self.approve_above:
211
+ sensitive, sensitive_reason = True, f"金额 ¥{amount:.2f} > 敏感阈值 ¥{self.approve_above:.2f}, 需确认"
212
+ if sensitive:
213
+ if appr is None:
214
+ rec = self._record(action=action, amount=amount, to=to, agent=agent,
215
+ decision="blocked_approval",
216
+ reason=sensitive_reason + ", 未配置审批通道, 安全默认拒绝",
217
+ spent_after=self._spent)
218
+ self.log(rec)
219
+ if self.on_block:
220
+ self.on_block(rec)
221
+ raise NeedsApproval(f"[确认] {action} ¥{amount} -> {to} 未获批准({sensitive_reason}, 未配置审批通道)")
222
+ ok = self._ask(action, amount, to, agent)
223
+ if not ok:
224
+ rec = self._record(action=action, amount=amount, to=to, agent=agent,
225
+ decision="blocked_approval", reason=sensitive_reason + ", 审批被拒",
226
+ spent_after=self._spent)
227
+ self.log(rec)
228
+ if self.on_block:
229
+ self.on_block(rec)
230
+ raise NeedsApproval(f"[确认] {action} ¥{amount} -> {to} 未获批准({sensitive_reason})")
231
+ elif appr is not None:
232
+ ok = self._ask(action, amount, to, agent)
233
+ if not ok:
234
+ rec = self._record(action=action, amount=amount, to=to, agent=agent,
235
+ decision="blocked_approval", reason="人工确认被拒",
236
+ spent_after=self._spent)
237
+ self.log(rec)
238
+ if self.on_block:
239
+ self.on_block(rec)
240
+ raise NeedsApproval(f"[确认] {action} ¥{amount} -> {to} 未获批准")
241
+ return self._record(action=action, amount=amount, to=to, agent=agent,
242
+ decision="executed", reason="", spent_after=self._spent)
243
+
244
+ def _ask(self, action: str, amount: float, to: str, agent: str = "") -> bool:
245
+ who = f"[{agent}] " if agent else ""
246
+ if callable(self.approval):
247
+ return bool(self.approval({"action": action, "amount": amount, "to": to, "agent": agent}))
248
+ if self.approval == "console":
249
+ try:
250
+ ans = input(f"\n⚠️ {who}{action} ¥{amount:.2f} -> {to}\n 确认执行? [y/N] ").strip().lower()
251
+ return ans in ("y", "yes")
252
+ except EOFError:
253
+ return False
254
+ if self.approval == "tg":
255
+ return self._ask_tg(action, amount, to, agent=agent)
256
+ if self.approval == "webhook":
257
+ return self._ask_webhook(action, amount, to, agent=agent)
258
+ return False # 未知模式 = 拒绝(安全默认)
259
+
260
+ def _ask_tg(self, action: str, amount: float, to: str, timeout_s: int = 60, agent: str = "") -> bool:
261
+ """TG 远程审批: 发消息等回复 y/n"""
262
+ import urllib.request
263
+ if not self.tg_token or not self.tg_chat:
264
+ return False
265
+ try:
266
+ who = f"[{agent}] " if agent else ""
267
+ text = f"⚠️ SpendShield 审批\n{who}{action} ¥{amount:.2f} -> {to}\n回复 y 确认 / n 拒绝"
268
+ url = f"https://api.telegram.org/bot{self.tg_token}/sendMessage"
269
+ body = json.dumps({"chat_id": self.tg_chat, "text": text}).encode()
270
+ req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"})
271
+ urllib.request.urlopen(req, timeout=10).read()
272
+ # 轮询等回复
273
+ deadline = time.time() + timeout_s
274
+ while time.time() < deadline:
275
+ up_url = f"https://api.telegram.org/bot{self.tg_token}/getUpdates?timeout=10&offset={self._tg_offset}"
276
+ try:
277
+ with urllib.request.urlopen(up_url, timeout=15) as r:
278
+ updates = json.loads(r.read()).get("result", [])
279
+ except Exception:
280
+ updates = []
281
+ for u in updates:
282
+ self._tg_offset = u["update_id"] + 1
283
+ msg_text = ((u.get("message") or {}).get("text") or "").strip().lower()
284
+ if msg_text in ("y", "yes", "确认", "同意"):
285
+ return True
286
+ if msg_text in ("n", "no", "拒绝", "取消"):
287
+ return False
288
+ time.sleep(1)
289
+ return False
290
+ except Exception:
291
+ return False
292
+
293
+ def _ask_webhook(self, action: str, amount: float, to: str, timeout_s: int = 15, agent: str = "") -> bool:
294
+ """Webhook 远程审批: POST 到审核服务, 等 {approved: bool}"""
295
+ import urllib.request
296
+ if not self.webhook_url:
297
+ return False
298
+ try:
299
+ body = json.dumps({"action": action, "amount": amount, "to": to, "agent": agent}).encode()
300
+ req = urllib.request.Request(self.webhook_url, data=body,
301
+ headers={"Content-Type": "application/json"})
302
+ with urllib.request.urlopen(req, timeout=timeout_s) as r:
303
+ resp = json.loads(r.read().decode("utf-8", "replace"))
304
+ return bool(resp.get("approved"))
305
+ except Exception:
306
+ return False
307
+
308
+ def protect(self, action: str, max_amount: float = 0.0, agent: str = ""):
309
+ """
310
+ 装饰器: 给花钱函数加闸门。
311
+ max_amount: 单次上限(0 = 不限)
312
+ agent: Agent 身份 ID(建议必填, 未注册默认拒绝)。也可运行时传 kwargs agent=xx
313
+ 函数签名需能取出金额和收款方: 参数名 amount/price/cost + to/recipient/target,
314
+ 或显式传 amount=xx / to=xx(支持位置传参)
315
+ """
316
+ def deco(fn: Callable) -> Callable:
317
+ @functools.wraps(fn)
318
+ def wrapper(*args, **kwargs):
319
+ ag = kwargs.get("agent") or agent or ""
320
+ amount = self._extract_amount(fn, args, kwargs)
321
+ to = self._extract_to(fn, args, kwargs)
322
+ if "agent" in kwargs and "agent" not in inspect.signature(fn).parameters:
323
+ kwargs.pop("agent") # 不透传给业务函数
324
+ if max_amount > 0 and amount > max_amount:
325
+ rec = self._record(action=action, amount=amount, to=to, agent=ag,
326
+ decision="blocked_budget",
327
+ reason=f"单次 ¥{amount:.2f} > 上限 ¥{max_amount:.2f}",
328
+ spent_after=self._spent)
329
+ self.log(rec)
330
+ if self.on_block:
331
+ self.on_block(rec)
332
+ raise BudgetExceeded(f"[单次上限] {action} ¥{amount} 超过 ¥{max_amount}")
333
+ # 通过闸门(执行前记录, 执行后更新已花)
334
+ rec = self._check(action, amount, to, agent=ag)
335
+ try:
336
+ result = fn(*args, **kwargs)
337
+ except Exception as e:
338
+ rec.decision = "failed"
339
+ rec.reason = str(e)[:120]
340
+ self.log(rec)
341
+ raise
342
+ self._spent += amount
343
+ if ag:
344
+ self._agent_spent[ag] = self._agent_spent.get(ag, 0.0) + amount
345
+ self._known_recipients.add(to.lower()) # 交易成功 → 记为已知收款方
346
+ rec.spent_after = self._spent
347
+ self.log(rec)
348
+ return result
349
+ return wrapper
350
+ return deco
351
+
352
+ @staticmethod
353
+ def _extract_amount(fn: Callable, args: tuple, kwargs: dict) -> float:
354
+ """从参数里找金额: 优先显式 amount=, 其次参数名含 amount/price/cost/价"""
355
+ if "amount" in kwargs:
356
+ return float(kwargs["amount"])
357
+ sig = inspect.signature(fn)
358
+ names = list(sig.parameters.keys())
359
+ for i, nm in enumerate(names):
360
+ if any(k in nm.lower() for k in ("amount", "price", "cost")):
361
+ if i < len(args):
362
+ return float(args[i])
363
+ if nm in kwargs:
364
+ return float(kwargs[nm])
365
+ return 0.0
366
+
367
+ def get_secret(self, name: str, *, action: str = "取密钥", agent: str = "", to: str = "") -> str:
368
+ """密钥保险库取用: 必须先过闸门(身份 + 意图审批), 取用留审计。
369
+ name: 密钥名(视为收款方, 可加白名单免问); 未配置审批通道时新密钥名默认拒绝。"""
370
+ if self.vault is None:
371
+ raise ValueError("未配置 KeyVault: SpendShield(key_vault=KeyVault(...))")
372
+ self._check(action, 0.0, to or name, agent=agent) # 走身份 + 敏感审批闸门
373
+ secret = self.vault.retrieve(name)
374
+ rec = self._record(action=action, amount=0.0, to=to or name, agent=agent,
375
+ decision="secret_access", reason=f"密钥 {name} 已取用",
376
+ spent_after=self._spent)
377
+ self.log(rec)
378
+ return secret
379
+
380
+ def register_agent(self, agent_id: str, *, budget: float = 0.0, max_amount: float = 0.0,
381
+ blacklist: Optional[list] = None, whitelist: Optional[list] = None,
382
+ rate_limit: Optional[dict] = None, approval: Optional[Any] = None) -> None:
383
+ """注册 Agent 身份及其专属策略(KYA 最小实现)。未注册的 agent 调用默认被拒。"""
384
+ if not agent_id:
385
+ raise ValueError("agent_id 不能为空")
386
+ self._agents[agent_id] = {
387
+ "budget": float(budget or 0),
388
+ "max_amount": float(max_amount or 0),
389
+ "blacklist": [str(x).lower() for x in (blacklist or [])],
390
+ "whitelist": [str(x).lower() for x in (whitelist or [])],
391
+ "rate_limit": rate_limit or {},
392
+ "approval": approval,
393
+ }
394
+
395
+ def _agent_policy(self, agent_id: str) -> dict:
396
+ """解析 Agent 身份: 未注册默认拒绝(安全默认), allow_unknown=True 回落全局策略"""
397
+ if not agent_id:
398
+ return {}
399
+ if agent_id not in self._agents:
400
+ if self.allow_unknown:
401
+ return {}
402
+ raise UnknownAgent(f"未注册的 Agent 身份: {agent_id!r}(先 register_agent 或 allow_unknown=True)")
403
+ return self._agents[agent_id]
404
+
405
+ @staticmethod
406
+ def _extract_to(fn: Callable, args: tuple, kwargs: dict) -> str:
407
+ """从参数里提取收款方: 优先显式 to=, 其次参数名 to/recipient/target(支持位置传参)"""
408
+ if "to" in kwargs:
409
+ return str(kwargs["to"])
410
+ sig = inspect.signature(fn)
411
+ names = list(sig.parameters.keys())
412
+ for i, nm in enumerate(names):
413
+ if nm in ("to", "recipient", "target"):
414
+ if i < len(args):
415
+ return str(args[i])
416
+ if nm in kwargs:
417
+ return str(kwargs[nm])
418
+ return "(unknown)"
419
+
420
+ def load_policy(self, path: str):
421
+ """从 YAML 策略文件加载配置(策略即代码)"""
422
+ import yaml
423
+ with open(path, encoding="utf-8") as f:
424
+ cfg = yaml.safe_load(f) or {}
425
+ for k in ("budget", "dry_run", "approval"):
426
+ if k in cfg:
427
+ setattr(self, k, cfg[k])
428
+ if "max_amount" in cfg:
429
+ self.default_max_amount = cfg["max_amount"]
430
+ if cfg.get("blacklist"):
431
+ self.blacklist = [str(x).lower() for x in cfg["blacklist"]]
432
+ if cfg.get("whitelist"):
433
+ self.whitelist = [str(x).lower() for x in cfg["whitelist"]]
434
+ if cfg.get("rate_limit"):
435
+ self.rate_limit = cfg["rate_limit"]
436
+ if cfg.get("tg"):
437
+ self.tg_token = cfg["tg"].get("token", self.tg_token)
438
+ self.tg_chat = cfg["tg"].get("chat", self.tg_chat)
439
+ if cfg.get("webhook_url"):
440
+ self.webhook_url = cfg["webhook_url"]
441
+ if cfg.get("allow_unknown") is not None:
442
+ self.allow_unknown = bool(cfg["allow_unknown"])
443
+ if cfg.get("approve_new_recipient") is not None:
444
+ self.approve_new_recipient = bool(cfg["approve_new_recipient"])
445
+ if cfg.get("approve_above") is not None:
446
+ self.approve_above = float(cfg["approve_above"])
447
+ if cfg.get("vault"):
448
+ from .vault import KeyVault
449
+ vpath = cfg["vault"].get("path", "spendshield_vault.json")
450
+ venv = cfg["vault"].get("master_key_env", "SPENDGUARD_MASTER_KEY")
451
+ mk = os.environ.get(venv) if venv else None
452
+ if mk:
453
+ self.vault = KeyVault(vpath, master_key=mk)
454
+ for aid, aconf in (cfg.get("agents") or {}).items():
455
+ self.register_agent(aid, **{k: v for k, v in aconf.items()
456
+ if k in ("budget", "max_amount", "blacklist",
457
+ "whitelist", "rate_limit", "approval")})
458
+ return self
459
+
460
+ def _authorize(self, action: str, amount: float, to: str, agent: str = "") -> bool:
461
+ """MCP/程序化调用入口: 走全部闸门, 通过返回 True, 被拦抛异常"""
462
+ ap = self._agent_policy(agent)
463
+ amax = float(ap.get("max_amount", 0) or 0) or self.default_max_amount
464
+ if amax > 0 and amount > amax:
465
+ rec = self._record(action=action, amount=amount, to=to, agent=agent,
466
+ decision="blocked_budget",
467
+ reason=f"单次 ¥{amount:.2f} > 上限 ¥{amax:.2f}",
468
+ spent_after=self._spent)
469
+ self.log(rec)
470
+ raise BudgetExceeded(f"[单次上限] {action} ¥{amount} 超过 ¥{amax}")
471
+ self._check(action, amount, to, agent=agent)
472
+ return True
473
+
474
+ def summary(self) -> dict:
475
+ agents = {
476
+ k: {
477
+ "budget": v.get("budget", 0),
478
+ "spent": round(self._agent_spent.get(k, 0.0), 2),
479
+ "blocked": sum(1 for r in self.records
480
+ if r.agent == k and (r.decision.startswith("blocked") or r.decision == "dry_run")),
481
+ }
482
+ for k in self._agents
483
+ }
484
+ return {
485
+ "dry_run": self.dry_run,
486
+ "budget": self.budget,
487
+ "spent": round(self._spent, 2),
488
+ "remaining": round(max(self.budget - self._spent, 0), 2) if self.budget > 0 else None,
489
+ "records": len(self.records),
490
+ "blocked": sum(1 for r in self.records if r.decision.startswith("blocked") or r.decision == "dry_run"),
491
+ "executed": sum(1 for r in self.records if r.decision == "executed"),
492
+ "known_recipients": len(self._known_recipients),
493
+ "agents": agents,
494
+ }
495
+
496
+ def export_audit(self, path: str = "spendshield_audit.json") -> str:
497
+ """导出审计日志(自动创建父目录)"""
498
+ d = os.path.dirname(os.path.abspath(path))
499
+ if d:
500
+ os.makedirs(d, exist_ok=True)
501
+ with open(path, "w", encoding="utf-8") as f:
502
+ json.dump([r.to_dict() for r in self.records], f, ensure_ascii=False, indent=2)
503
+ return path
@@ -0,0 +1,185 @@
1
+ # -*- coding: utf-8 -*-
2
+ """SpendShield MCP Server — AI Agent 直接调用付款护栏
3
+
4
+ 让 Claude Code / OpenClaw / 任何 MCP 兼容 agent 通过工具调用过 SpendShield 闸门。
5
+
6
+ 协议: MCP stdio transport (newline-delimited JSON-RPC 2.0)
7
+ 工具:
8
+ - spend_protect: 保护一次花钱操作(走全部闸门)
9
+ - spend_status: 查询预算/已花/策略状态
10
+ - spend_audit: 最近审计记录
11
+ - spend_reset: 重置会话已花金额
12
+
13
+ 用法:
14
+ python -m spendshield.mcp # 默认无策略
15
+ python -m spendshield.mcp --policy xx.yaml
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import os
22
+ import sys
23
+
24
+ from .guard import SpendShield, DryRunBlocked, BudgetExceeded, NeedsApproval, UnknownAgent
25
+
26
+ PROTOCOL_VERSION = "2024-11-05"
27
+ SERVER_NAME = "spendshield"
28
+ SERVER_VERSION = "0.2.0"
29
+
30
+
31
+ def _tool_schema(name: str, desc: str, props: dict, required: list) -> dict:
32
+ return {"name": name, "description": desc,
33
+ "inputSchema": {"type": "object", "properties": props, "required": required}}
34
+
35
+
36
+ TOOLS = [
37
+ _tool_schema("spend_protect", "保护一次花钱操作。走干跑/预算/黑名单/白名单/频率/单次上限闸门。"
38
+ "通过返回 ok=true; 被拦返回 ok=false + reason",
39
+ {"action": {"type": "string", "description": "操作名, 如 下单/转账/充值"},
40
+ "amount": {"type": "number", "description": "金额(元)"},
41
+ "to": {"type": "string", "description": "收款方, 如 麦当劳/xxx@example.com"},
42
+ "agent": {"type": "string", "description": "调用方 Agent 身份 ID(未注册默认拒绝, 建议必填)"}},
43
+ ["action", "amount", "to"]),
44
+ _tool_schema("spend_status", "查询当前护栏状态: 预算/已花/剩余/拦截统计", {}, []),
45
+ _tool_schema("spend_audit", "最近审计记录(最多 N 条)",
46
+ {"limit": {"type": "integer", "description": "条数, 默认 10"}}, []),
47
+ _tool_schema("spend_reset", "重置本次会话已花金额(新会话/换预算时用)", {}, []),
48
+ _tool_schema("secret_get", "从密钥保险库取密钥(过身份+审批闸门, 留审计)。密钥名视为收款方, 可加白名单免问",
49
+ {"name": {"type": "string", "description": "密钥名(如 mcd_sk)"},
50
+ "agent": {"type": "string", "description": "调用方 Agent 身份 ID"}},
51
+ ["name", "agent"]),
52
+ ]
53
+
54
+
55
+ class SpendShieldMCP:
56
+ def __init__(self, guard: SpendShield):
57
+ self.guard = guard
58
+
59
+ # ---------- MCP 方法 ----------
60
+ def initialize(self, params: dict) -> dict:
61
+ return {"protocolVersion": PROTOCOL_VERSION,
62
+ "capabilities": {"tools": {}},
63
+ "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION}}
64
+
65
+ def tools_list(self) -> dict:
66
+ return {"tools": TOOLS}
67
+
68
+ def tools_call(self, name: str, arguments: dict) -> dict:
69
+ try:
70
+ result = self._dispatch(name, arguments or {})
71
+ return {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}],
72
+ "isError": False}
73
+ except Exception as e:
74
+ return {"content": [{"type": "text", "text": f"error: {str(e)[:200]}"}],
75
+ "isError": True}
76
+
77
+ def _dispatch(self, name: str, args: dict):
78
+ if name == "spend_protect":
79
+ return self._protect(args)
80
+ if name == "spend_status":
81
+ return self.guard.summary()
82
+ if name == "spend_audit":
83
+ limit = int(args.get("limit", 10))
84
+ return {"records": [r.to_dict() for r in self.guard.records[-limit:]]}
85
+ if name == "spend_reset":
86
+ self.guard._spent = 0.0
87
+ return {"ok": True, "message": "已花金额已重置"}
88
+ if name == "secret_get":
89
+ return self._secret_get(args)
90
+ raise ValueError(f"未知工具: {name}")
91
+
92
+ def _protect(self, args: dict) -> dict:
93
+ action = args.get("action", "?")
94
+ amount = float(args.get("amount", 0))
95
+ to = args.get("to", "?")
96
+ agent = args.get("agent", "")
97
+ try:
98
+ # 手动走闸门(不依赖装饰器): dry_run/黑名单/频率/预算/确认
99
+ ok = self.guard._authorize(action, amount, to, agent)
100
+ if not ok:
101
+ return {"ok": False, "reason": "审批被拒",
102
+ "spent": self.guard.spent}
103
+ self.guard._spent += amount
104
+ if agent:
105
+ self.guard._agent_spent[agent] = self.guard._agent_spent.get(agent, 0.0) + amount
106
+ self.guard._known_recipients.add(to.lower())
107
+ self.guard._record(action=action, amount=amount, to=to, agent=agent,
108
+ decision="executed", reason="mcp 调用通过",
109
+ spent_after=self.guard.spent)
110
+ return {"ok": True, "approved": True, "amount": amount, "to": to,
111
+ "spent": self.guard.spent,
112
+ "note": "已放行。请在真实支付前调用你的支付接口"}
113
+ except (DryRunBlocked, BudgetExceeded, NeedsApproval, UnknownAgent) as e:
114
+ return {"ok": False, "reason": str(e), "spent": self.guard.spent}
115
+
116
+ def _secret_get(self, args: dict) -> dict:
117
+ name = args.get("name", "")
118
+ agent = args.get("agent", "")
119
+ if self.guard.vault is None:
120
+ return {"ok": False, "reason": "未配置 KeyVault(需设置 SPENDGUARD_MASTER_KEY + vault 路径)"}
121
+ try:
122
+ secret = self.guard.get_secret(name, agent=agent)
123
+ return {"ok": True, "name": name, "secret": secret}
124
+ except (DryRunBlocked, BudgetExceeded, NeedsApproval, UnknownAgent, KeyError) as e:
125
+ return {"ok": False, "reason": str(e)}
126
+
127
+ # ---------- JSON-RPC 分发 ----------
128
+ def handle(self, line: str) -> str | None:
129
+ """处理一行 JSON-RPC, 返回响应行(通知/错误返回 None)"""
130
+ try:
131
+ msg = json.loads(line)
132
+ except json.JSONDecodeError:
133
+ return None
134
+ msg_id = msg.get("id")
135
+ method = msg.get("method")
136
+ params = msg.get("params") or {}
137
+
138
+ if method == "initialize":
139
+ result = self.initialize(params)
140
+ elif method == "tools/list":
141
+ result = self.tools_list()
142
+ elif method == "tools/call":
143
+ result = self.tools_call(params.get("name", ""), params.get("arguments") or {})
144
+ elif method == "notifications/initialized":
145
+ return None
146
+ elif method == "ping":
147
+ result = {}
148
+ else:
149
+ # 未知方法: 返回错误
150
+ if msg_id is not None:
151
+ return json.dumps({"jsonrpc": "2.0", "id": msg_id,
152
+ "error": {"code": -32601, "message": f"method not found: {method}"}})
153
+ return None
154
+ if msg_id is None:
155
+ return None
156
+ return json.dumps({"jsonrpc": "2.0", "id": msg_id, "result": result}, ensure_ascii=False)
157
+
158
+
159
+ def main():
160
+ ap = argparse.ArgumentParser(prog="spendshield-mcp")
161
+ ap.add_argument("--policy", default=os.environ.get("SPENDGUARD_POLICY", ""),
162
+ help="策略文件路径(spendshield.yaml)")
163
+ ap.add_argument("--budget", type=float, default=0.0)
164
+ ap.add_argument("--dry-run", action="store_true", default=True, help="干跑模式(默认开)")
165
+ ap.add_argument("--no-dry-run", action="store_true", help="关闭干跑")
166
+ args = ap.parse_args()
167
+
168
+ guard = SpendShield(budget=args.budget, dry_run=not args.no_dry_run,
169
+ log=lambda rec: print(f"[SpendShield] {rec.decision}: {rec.action} \u00a5{rec.amount} -> {rec.to}", file=sys.stderr))
170
+ if args.policy:
171
+ guard.load_policy(args.policy)
172
+ mcp = SpendShieldMCP(guard)
173
+
174
+ for line in sys.stdin:
175
+ line = line.strip()
176
+ if not line:
177
+ continue
178
+ resp = mcp.handle(line)
179
+ if resp:
180
+ sys.stdout.write(resp + "\n")
181
+ sys.stdout.flush()
182
+
183
+
184
+ if __name__ == "__main__":
185
+ main()
spendshield/vault.py ADDED
@@ -0,0 +1,75 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ SpendShield KeyVault — 密钥保险库(Fireblocks 最小版)
4
+
5
+ 私钥/令牌加密落盘, 主密钥不落盘(环境变量持有)。
6
+ 取密钥必须经过 SpendShield 闸门(身份 + 意图审批), 每次取用留审计。
7
+
8
+ 用法:
9
+ from spendshield import SpendShield, KeyVault
10
+
11
+ # 首次: 生成主密钥, 放进环境变量 SPENDGUARD_MASTER_KEY(别落盘!)
12
+ # python -c "from spendshield import KeyVault; print(KeyVault.generate_key())"
13
+ vault = KeyVault("vault.json", master_key=os.environ["SPENDGUARD_MASTER_KEY"])
14
+ vault.store("mcd_sk", "sk_live_xxxx") # 加密落盘, 文件里无明文
15
+
16
+ guard = SpendShield(key_vault=vault, approval="console")
17
+ guard.register_agent("mcd_bot", whitelist=["mcd_sk"]) # 密钥名加白名单免问
18
+ sk = guard.get_secret("mcd_sk", agent="mcd_bot") # 过闸门才能取
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ from typing import Optional
25
+
26
+ from cryptography.fernet import Fernet
27
+
28
+
29
+ class KeyVault:
30
+ """加密密钥保险库: AES128-CBC + HMAC(Fernet), 主密钥不落盘"""
31
+
32
+ def __init__(self, path: str = "spendshield_vault.json", master_key: Optional[str] = None):
33
+ self.path = path
34
+ if master_key is None:
35
+ master_key = os.environ.get("SPENDGUARD_MASTER_KEY")
36
+ if not master_key:
37
+ raise ValueError(
38
+ "需要主密钥: 传入 master_key 或设置环境变量 SPENDGUARD_MASTER_KEY "
39
+ "(用 KeyVault.generate_key() 生成)"
40
+ )
41
+ if isinstance(master_key, str):
42
+ master_key = master_key.encode()
43
+ self._fernet = Fernet(master_key)
44
+ self._data = self._load()
45
+
46
+ @staticmethod
47
+ def generate_key() -> str:
48
+ """生成主密钥(仅打印一次, 放进环境变量, 别落盘)"""
49
+ return Fernet.generate_key().decode()
50
+
51
+ def store(self, name: str, value: str) -> None:
52
+ """加密存储密钥(落盘文件里只有密文)"""
53
+ if not name or not value:
54
+ raise ValueError("name/value 不能为空")
55
+ self._data[name] = self._fernet.encrypt(value.encode()).decode()
56
+ self._save()
57
+
58
+ def retrieve(self, name: str) -> str:
59
+ """解密取回密钥(调用方负责先过 SpendShield 闸门)"""
60
+ if name not in self._data:
61
+ raise KeyError(f"密钥不存在: {name}")
62
+ return self._fernet.decrypt(self._data[name].encode()).decode()
63
+
64
+ def names(self) -> list:
65
+ return sorted(self._data.keys())
66
+
67
+ def _load(self) -> dict:
68
+ if os.path.exists(self.path):
69
+ with open(self.path, encoding="utf-8") as f:
70
+ return json.load(f)
71
+ return {}
72
+
73
+ def _save(self) -> None:
74
+ with open(self.path, "w", encoding="utf-8") as f:
75
+ json.dump(self._data, f, indent=2)
@@ -0,0 +1,178 @@
1
+ Metadata-Version: 2.4
2
+ Name: spendshield
3
+ Version: 0.6.0
4
+ Summary: AI Agent 付款安全层: 干跑/预算/人工确认/审计 四道闸门
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: cryptography>=41
9
+
10
+ # 💰 SpendShield — AI Agent 付款安全层
11
+
12
+ > **AI 替你花钱之前,先过 SpendShield 这关。**
13
+
14
+ 让 AI Agent 下单、转账、调付费 API 之前,自动过四道闸门:
15
+ **干跑预览 → 预算上限 → 人工确认 → 全量审计。**
16
+
17
+ ## 🩸 为什么会有这个项目(真实事故)
18
+
19
+ 2026 年 8 月 9 日,我的自动化系统测试下单。
20
+
21
+ 我传了 `dry: true`,以为只是试算价格。但服务器只认 `?dry=1` —— **4 单 99 元真实出码扣款,当天全部打水漂。**
22
+
23
+ 这不是我一个人的坑。AI Agent 时代正在到来:Agent 替你订餐、替你充值、替你调付费 API——**当 AI 开始花真钱,谁给它上闸门?**
24
+
25
+ 我把我踩过的坑,做成了一个库。
26
+
27
+ ## ✨ 四道闸门
28
+
29
+ | 闸门 | 默认 | 作用 |
30
+ |------|------|------|
31
+ | 🧪 **dry_run** 干跑 | ✅ 开 | 只预览不执行——`dry` 参数失效也无所谓,库层面兜底 |
32
+ | 💰 **budget** 预算 | 不限 | 总预算超支直接拒绝,绝不超花 |
33
+ | 🚧 **max_amount** 单次上限 | 不限 | 单笔超限拦截(防"转 9999 给陌生人") |
34
+ | 🙋 **approval** 人工确认 | 关 | 花钱前必须人点头(console / 回调) |
35
+ | 📜 **audit** 审计 | ✅ 开 | 每次尝试全留痕,导出 JSON 对账 |
36
+
37
+ ## 🔑 身份层(KYA 最小实现,v0.4)
38
+
39
+ AI 没有法律人格,但必须有“数字身份”。每个 agent 注册专属策略,**未注册默认拒绝**:
40
+
41
+ ```python
42
+ from spendshield import SpendShield, UnknownAgent
43
+
44
+ guard = SpendShield(dry_run=False)
45
+ guard.register_agent("mcd_bot", budget=50, max_amount=30,
46
+ blacklist=["测试收款"], whitelist=["麦当劳"],
47
+ rate_limit={"window_s": 60, "max_calls": 3})
48
+
49
+ @guard.protect("下单", agent="mcd_bot") # 或运行时传 agent=xx
50
+
51
+ def place_order(amount, to):
52
+ return call_real_api(amount, to)
53
+ ```
54
+
55
+ - 未注册的 agent 调用 → 直接拒绝(`UnknownAgent`),审计留痕 `blocked_unknown_agent`
56
+ - `allow_unknown: true` 可回落全局策略(不推荐)
57
+ - 每条审计记录带 `agent` 字段:**谁在花、花给谁、用户知不知道**
58
+ - 策略即代码支持 `agents:` 段(YAML),预算/黑名单/频率/审批按 agent 隔离
59
+
60
+ ## 🎯 意图一致性(防提示注入,v0.5)
61
+
62
+ AI 可能被劫持:提示注入、返利诱惑……闸门只知道“花多少、给谁”,不知道“这是用户要的吗”。
63
+ 解法:**敏感操作强制人工确认**——即使没配全局审批,新收款方/大额也默认拦下:
64
+
65
+ ```python
66
+ # 新收款方(从未交易过)→ 必须确认;没配审批通道 → 直接拒绝
67
+ # 金额 > approve_above → 必须确认
68
+ guard = SpendShield(approve_new_recipient=True, approve_above=1000)
69
+ ```
70
+
71
+ - 交易成功的收款方自动进入记忆,之后不再反复烦你
72
+ - 白名单收款方永远跳过
73
+ - 未配置审批通道时,敏感操作**直接拒绝**(宁可拦死,不放行)
74
+ - 拦截记录 `blocked_approval` 带原因:新收款方 / 超阈值 / 未配置通道
75
+
76
+ ## 🔐 密钥保险库(v0.6)
77
+
78
+ 私钥不落地是 AI 支付的命门——**一次泄露,钱包被掏空**。密钥加密落盘,主密钥放环境变量,取用必须过闸门:
79
+
80
+ ```bash
81
+ python -c "from spendshield import KeyVault; print(KeyVault.generate_key())" # 生成主密钥(仅此一次)
82
+ export SPENDGUARD_MASTER_KEY=<刚才的输出> # 放环境变量, 别写进代码/仓库
83
+ ```
84
+
85
+ ```python
86
+ from spendshield import SpendShield, KeyVault
87
+
88
+ vault = KeyVault("vault.json") # 主密钥从环境变量读
89
+ vault.store("mcd_sk", "sk_live_xxxx") # 加密落盘, 文件里只有密文
90
+
91
+ guard = SpendShield(key_vault=vault)
92
+ guard.register_agent("mcd_bot", whitelist=["mcd_sk"])
93
+ sk = guard.get_secret("mcd_sk", agent="mcd_bot") # 过身份+意图闸门才能取
94
+ ```
95
+
96
+ - 落盘文件无明文(AES128-CBC + HMAC);主密钥不落盘
97
+ - 取密钥 = 敏感操作:未注册 agent 拒绝;新密钥名无审批通道默认拒绝(防提示注入偷密钥)
98
+ - 每次取用留审计 `secret_access`:谁、何时、取了哪个密钥
99
+
100
+ ## 🚀 快速开始
101
+
102
+ ```bash
103
+ pip install spendshield # 或直接 clone 用
104
+ ```
105
+
106
+ ```python
107
+ from spendshield import SpendShield
108
+
109
+ guard = SpendShield(budget=200, dry_run=True, whitelist=["麦当劳"]) # 默认干跑 + 信任收款方
110
+
111
+ @guard.protect("下单")
112
+ def place_order(amount, to):
113
+ return call_real_api(amount, to) # 真实下单逻辑
114
+
115
+ # 干跑模式: 报错提示, 绝不真花
116
+ place_order(amount=99, to="麦当劳")
117
+ # => [SpendShield] dry_run: 下单 ¥99.0 -> 麦当劳 (未执行)
118
+ # => DryRunBlocked: 关掉 dry_run 才会真花
119
+
120
+ # 确认无误后放行, 预算闸门兜底
121
+ guard.dry_run = False
122
+ for i in range(4):
123
+ place_order(amount=99, to="麦当劳") # 第3单被 BudgetExceeded 拦住
124
+ ```
125
+
126
+ > 💡 新收款方默认需人工确认(意图一致性, 防提示注入)——把常用收款方加白名单或注册 Agent 身份可免。
127
+
128
+ ## 🎯 谁需要它
129
+
130
+ - **AI Agent 框架用户**:给你的 Agent 工具加装饰器,一行接入
131
+ - **自动化系统运维**:批量任务/定时下单,防误操作真扣款
132
+ - **MCP / Function Call 开发者**:LLM 生成的工具调用,过闸门再执行
133
+ - **所有被"测试单变真单"坑过的人** 🩸
134
+
135
+ ## 🗺 Roadmap
136
+
137
+ - [x] v0.1 四道闸门 + 审计 + 装饰器接入
138
+ - [ ] 收款方黑名单/白名单(陌生收款方强制确认)
139
+ - [ ] 频率限制(同一收款方短时间 N 次)
140
+ - [ ] MCP server 版(Agent 工具调用直接过闸)
141
+ - [ ] 远程审批(企业微信/Telegram 确认)
142
+ - [ ] 多策略插件(风控规则引擎)
143
+
144
+ ## 🧪 测试
145
+
146
+ ```bash
147
+ python3 tests/test_guard.py # 6 个测试全过
148
+ ```
149
+
150
+ ## 📄 License
151
+
152
+ MIT — 拿去用。愿 AI 时代,没人再被"测试单"坑第二次。
153
+
154
+ ---
155
+
156
+ **⭐ 如果这个项目对你有用,点个 star,让更多被坑过的人看到。**
157
+
158
+ ## 🤖 MCP Server(AI Agent 直接调用)
159
+
160
+ 让 Claude Code / OpenClaw 等 MCP 兼容 agent 直接通过工具调用过闸门:
161
+
162
+ ```bash
163
+ # 启动(stdio 模式, agent 配置里指向它)
164
+ python -m spendshield.mcp_server --policy spendshield.yaml
165
+ # 或安装后: spendshield-mcp --policy spendshield.yaml
166
+ ```
167
+
168
+ **工具**:
169
+ - `spend_protect(action, amount, to)` — 保护一次花钱操作(走全部闸门)
170
+ - `spend_status()` — 预算/已花/拦截统计
171
+ - `spend_audit(limit)` — 最近审计记录
172
+ - `spend_reset()` — 重置会话已花
173
+
174
+ ```json
175
+ // agent 调用示例
176
+ {"name": "spend_protect", "arguments": {"action": "下单", "amount": 99, "to": "麦当劳"}}
177
+ // => {"ok": false, "reason": "[干跑] 下单 ¥99.0 -> 麦当劳 (未执行...)", "spent": 0.0}
178
+ ```
@@ -0,0 +1,10 @@
1
+ spendshield/__init__.py,sha256=78FpUvPzqGfpLMTJmlXwVWwD606QE5ghSfJpH_IV6B8,1156
2
+ spendshield/dashboard.py,sha256=Tse5yuvCTV96wTqJKelAumr0fLZEcrTHjQ64qw_J9jY,5399
3
+ spendshield/guard.py,sha256=ByfgB7Ya-_DtsErt82fg22ah6KS_U6sXlfBN87sJR2U,25047
4
+ spendshield/mcp_server.py,sha256=058Yqebq9oS0Wt1KUhcry4OL3EDaHvhF-qvMnJPZOfQ,8091
5
+ spendshield/vault.py,sha256=hiQXrA-WlLsPM7efTaxQtdVu4z_-xSqo6AqugSsc0Ek,2850
6
+ spendshield-0.6.0.dist-info/METADATA,sha256=l26HOIB-t7FQ56IwYOluck5s9rb8Rwvs8Qapd1XWfZ0,6948
7
+ spendshield-0.6.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ spendshield-0.6.0.dist-info/entry_points.txt,sha256=NSq5hGFyI-jUBxyJ7oEeewi4MrdtCHVbSHxbSGyxrco,64
9
+ spendshield-0.6.0.dist-info/top_level.txt,sha256=kQijlfLgo8JstfcO06W2qk_XeQpj3S5utRGfafE_e_w,12
10
+ spendshield-0.6.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ spendshield-mcp = spendshield.mcp_server:main
@@ -0,0 +1 @@
1
+ spendshield