foxclaw 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/index.js +1 -1
  2. package/package.json +1 -1
  3. package/python/.github/workflows/release.yml +5 -0
  4. package/python/CHANGELOG.md +43 -0
  5. package/python/CHANGELOG_CN.md +43 -0
  6. package/python/README.md +6 -0
  7. package/python/README_CN.md +6 -0
  8. package/python/docs/CONTENT_GUARD.md +189 -0
  9. package/python/docs/CONTENT_GUARD_CN.md +189 -0
  10. package/python/docs/RELEASE.md +74 -34
  11. package/python/foxclaw/__init__.py +41 -1
  12. package/python/foxclaw/agents/base.py +17 -0
  13. package/python/foxclaw/agents/mixins/memory.py +16 -2
  14. package/python/foxclaw/agents/mixins/prompt.py +59 -6
  15. package/python/foxclaw/agents/rules/finance/RULES.md +131 -0
  16. package/python/foxclaw/api/bootstrap.py +20 -2
  17. package/python/foxclaw/api/routes/memory.py +22 -0
  18. package/python/foxclaw/behavior/__init__.py +20 -0
  19. package/python/foxclaw/behavior/behavior.yaml +62 -0
  20. package/python/foxclaw/behavior/spec.py +287 -0
  21. package/python/foxclaw/core/content_guard.py +341 -0
  22. package/python/foxclaw/core/memory_adapter.py +24 -4
  23. package/python/foxclaw/core/model_router.py +38 -2
  24. package/python/foxclaw/core/sensitive_write.py +283 -0
  25. package/python/frontend/planet/planet.js +18 -0
  26. package/python/frontend/v3/assets/{index-D5nIsjms.css → index-JKfHlb-a.css} +1 -1
  27. package/python/frontend/v3/assets/index-tN90dpUH.js +34 -0
  28. package/python/frontend/v3/index.html +2 -2
  29. package/python/frontend_src/package.json +1 -1
  30. package/python/frontend_src/src/components/panels/MemoryPanel.vue +42 -2
  31. package/python/frontend_src/src/stores/panels.js +16 -3
  32. package/python/pyproject.toml +12 -1
  33. package/python/tests/run_all.py +1 -0
  34. package/python/tests/test_agent_rules.py +81 -0
  35. package/python/tests/test_behavior_spec.py +198 -0
  36. package/python/tests/test_conflict_dismiss.py +143 -0
  37. package/python/tests/test_content_guard.py +177 -0
  38. package/python/tests/test_sensitive_write.py +181 -0
  39. package/python/tools/build_content_lexicon.py +218 -0
  40. package/python/tools/bundle_web.py +98 -0
  41. package/python/tools/recheck_conflicts.py +156 -0
  42. package/python/tools/release_check.py +17 -8
  43. package/python/vendor/hkc/hkc_api/routes/ability.py +31 -0
  44. package/python/vendor/hkc/hkc_api/schemas.py +11 -0
  45. package/python/vendor/hkc/hkc_kee/kee.py +124 -3
  46. package/python/vendor/hkc/hkc_sdk/base.py +22 -0
  47. package/python/vendor/hkc/hkc_sdk/direct_client.py +10 -0
  48. package/python/vendor/hkc/hkc_sdk/http_client.py +19 -0
  49. package/python/frontend/v3/assets/index-CQSBdxk4.js +0 -34
@@ -23,6 +23,8 @@ from pathlib import Path
23
23
  from typing import Any
24
24
  from urllib.parse import urlsplit, urlunsplit
25
25
 
26
+ from .content_guard import StreamFilter
27
+
26
28
 
27
29
  def normalize_base_url(url: str) -> str:
28
30
  """把用户填的 BASE URL 收拾成 SDK 可直接用的形式(带 scheme + 至少一段 path)。
@@ -551,7 +553,7 @@ class ModelRouter:
551
553
  pass
552
554
  from .tracing import span as _span
553
555
  with _span("llm.complete", provider="stub"):
554
- return self._stub(messages)
556
+ return self._filter_output(self._stub(messages))
555
557
  import time as _t
556
558
  last_err = None
557
559
  _now = _t.monotonic()
@@ -598,7 +600,7 @@ class ModelRouter:
598
600
  return ("【模型返回为空】本次未获得有效回复,任务未完成。"
599
601
  "可能原因:内容被模型安全策略过滤,或 max_tokens 过小被截断。"
600
602
  "建议:调整提问方式或提高 max_tokens 后重试。")
601
- return output
603
+ return self._filter_output(output)
602
604
  self._mark_degraded(f"全部 provider 调用失败: {last_err}")
603
605
  return (f"【全部模型失败】已尝试 {len(self._fallback_order())} 个 provider 均失败,"
604
606
  f"本次任务未完成。最后错误:"
@@ -613,9 +615,43 @@ class ModelRouter:
613
615
  except Exception:
614
616
  pass
615
617
 
618
+ def _filter_output(self, text: str) -> str:
619
+ """输出端内容闸门的统一入口:命中即整段替换为固定话术,不保留原文片段。"""
620
+ guard = getattr(self, "content_guard", None)
621
+ if guard is None or not guard.enabled():
622
+ return text
623
+ d = guard.sanitize_output(text)
624
+ return d.text if d.blocked else text
625
+
616
626
  def stream(self, system: str, messages: list, max_tokens: int = 2048,
617
627
  agent_id: str = "", stop_event=None, images: list | None = None,
618
628
  on_thinking=None):
629
+ """流式文本补全的对外入口:在 `_stream_raw` 之上做输出端内容过滤。
630
+
631
+ 过滤放在这里而不是各 provider 分支里,是因为流式出口有四五处,
632
+ 逐处改容易漏(与 _OPENAI_FAMILY 分散同类的坑)。包装器只需一处。
633
+ """
634
+ raw = self._stream_raw(system, messages, max_tokens, agent_id,
635
+ stop_event, images, on_thinking)
636
+ cg = getattr(self, "content_guard", None)
637
+ if cg is None or not cg.enabled():
638
+ yield from raw
639
+ return
640
+ sf = StreamFilter(cg)
641
+ for piece in raw:
642
+ out = sf.feed(piece)
643
+ if sf.tripped:
644
+ yield sf.finish()
645
+ return
646
+ if out:
647
+ yield out
648
+ tail = sf.finish()
649
+ if tail:
650
+ yield tail
651
+
652
+ def _stream_raw(self, system: str, messages: list, max_tokens: int = 2048,
653
+ agent_id: str = "", stop_event=None, images: list | None = None,
654
+ on_thinking=None):
619
655
  """流式文本补全:逐块 yield。各 provider 用其流式 API;stub/异常降级分块。
620
656
  与 complete() 同为 LLM 咽喉,收尾做 token 计量。images(可选)= 多模态图片。
621
657
  stop_event:threading.Event,置位则提前终止(客户端断连时省 token)。
@@ -0,0 +1,283 @@
1
+ """高敏感记忆写入的同意闸门 (SensitiveWritePolicy)。
2
+
3
+ 问题
4
+ ────
5
+ `memory_adapter.ingest` 此前对「写什么内容」不做任何判定:凭据、证件号、诊断结论
6
+ 一旦被抽进长期记忆,就会在之后每次召回里反复出现在上下文中,而且会随 prompt 发往云端
7
+ 模型。spec 的 `confirm_high_sensitivity` 要求这类内容落库前得到用户显式同意。
8
+
9
+ 判定必须是确定性的
10
+ ──────────────────
11
+ 不用 LLM 判定敏感度:写入路径再调一次模型既贵,结果又不可复现,同一句话今天拦明天放,
12
+ 用户无法建立预期。这里只用正则 + Luhn 校验,命中即命中,可单测。
13
+
14
+ 误判方向是刻意的
15
+ ────────────────
16
+ 漏判 = 该拦的不拦,等于没做;误判 = 用户多点一次确认。所以宁可多拦:「我最近焦虑」
17
+ 也会被拦一次。反过来,财务 Agent 天天谈金额,因此 finance 类**只匹配卡号/账户号**,
18
+ 绝不匹配普通金额 —— 否则功能一开就无法使用。
19
+
20
+ 三档模式 (FOXCLAW_SENSITIVE_WRITE_MODE)
21
+ ──────────────────────────────────────
22
+ audit (默认) 只记录不拦截。落库照常,事件里标 allowed。
23
+ warn 记录 + 进程内告警,仍不落 pending。
24
+ enforce 真正拦截:高敏感内容不进长期记忆,转成待确认项,用户同意后才落库。
25
+
26
+ 为什么默认 audit
27
+ ────────────────
28
+ 分类规则在真实流量上跑通之前只是**待验证的假设**。先在 audit 下看清命中长什么样、
29
+ 误判率多少,再切 enforce —— 与 shared_write 的灰度立场一致。
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import os
35
+ import re
36
+ import threading
37
+ import uuid
38
+ from dataclasses import dataclass
39
+ from pathlib import Path
40
+ from typing import Any, Iterable
41
+
42
+ MODE_ENV = "FOXCLAW_SENSITIVE_WRITE_MODE"
43
+ VALID_MODES = ("audit", "warn", "enforce")
44
+
45
+ PENDING_FILE = "sensitive_pending.json"
46
+ MAX_PENDING = 200
47
+
48
+
49
+ def _mask(s: str) -> str:
50
+ """打码后才允许进事件/日志 —— 审计出口本身不能变成泄露点。"""
51
+ s = str(s or "")
52
+ if len(s) <= 6:
53
+ return "*" * len(s)
54
+ return f"{s[:2]}{'*' * (len(s) - 4)}{s[-2:]}"
55
+
56
+
57
+ def _luhn_ok(digits: str) -> bool:
58
+ total, alt = 0, False
59
+ for ch in reversed(digits):
60
+ if not ch.isdigit():
61
+ return False
62
+ d = int(ch)
63
+ if alt:
64
+ d *= 2
65
+ if d > 9:
66
+ d -= 9
67
+ total += d
68
+ alt = not alt
69
+ return total % 10 == 0
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class Hit:
74
+ category: str
75
+ pattern: str
76
+ sample: str
77
+
78
+ def to_dict(self) -> dict:
79
+ return {"category": self.category, "pattern": self.pattern, "sample": self.sample}
80
+
81
+
82
+ # 每个模式都是「有上下文锚点」的,不裸匹配长数字 —— 裸匹配会把订单号、时间戳全卷进来。
83
+ _RULES: dict[str, tuple[str, ...]] = {
84
+ "credential": (
85
+ r"(?:密码|口令|password|passwd|pwd)\s*[::=是为]\s*\S{3,}",
86
+ r"(?:api[_-]?key|secret|token|access[_-]?key)\s*[::=]\s*[A-Za-z0-9_\-]{12,}",
87
+ r"\bsk-[A-Za-z0-9]{16,}",
88
+ r"\bghp_[A-Za-z0-9]{20,}",
89
+ r"\bAKIA[0-9A-Z]{16}\b",
90
+ r"-----BEGIN [A-Z ]*PRIVATE KEY-----",
91
+ ),
92
+ "identity": (
93
+ r"\b[1-9]\d{5}(?:18|19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]\b",
94
+ r"(?:身份证|护照|港澳通行证|社保卡|驾驶证)[号编]?\s*[::]?\s*[A-Za-z0-9]{6,}",
95
+ ),
96
+ "health": (
97
+ r"(?:确诊|诊断为|病历|住院|手术|处方|服药|用药|过敏史|既往史)",
98
+ r"(?:抑郁症|焦虑症|糖尿病|高血压|心脏病|癌|HIV|乙肝)",
99
+ ),
100
+ "finance_detail": (
101
+ r"(?:银行卡|账户|卡号|账号)\s*[::]?\s*\d{6,}",
102
+ r"(?:CVV|CVC|安全码|有效期)\s*[::]?\s*\d{3,4}",
103
+ r"\b(?:信用卡|储蓄卡|借记卡)\b",
104
+ ),
105
+ }
106
+
107
+ _COMPILED = {cat: [re.compile(p) for p in pats] for cat, pats in _RULES.items()}
108
+
109
+ # 卡号单独走一次 Luhn:16-19 位裸数字只有校验通过才算,避免误伤订单号/流水号。
110
+ _CARD_CANDIDATE = re.compile(r"\b(?:\d[ -]?){15,18}\d\b")
111
+
112
+
113
+ class SensitivityClassifier:
114
+ """确定性判定:返回命中的类别与打码后的样本。无状态,并发安全。"""
115
+
116
+ def __init__(self, categories: Iterable[str] | None = None):
117
+ allowed = set(categories) if categories is not None else set(_COMPILED)
118
+ self.categories = tuple(c for c in _COMPILED if c in allowed)
119
+
120
+ def classify(self, content: str) -> tuple[Hit, ...]:
121
+ text = str(content or "")
122
+ if not text:
123
+ return ()
124
+ hits: list[Hit] = []
125
+ for cat in self.categories:
126
+ for rx in _COMPILED[cat]:
127
+ m = rx.search(text)
128
+ if m:
129
+ hits.append(Hit(cat, rx.pattern, _mask(m.group(0))))
130
+ break
131
+ if "finance_detail" in self.categories:
132
+ for m in _CARD_CANDIDATE.finditer(text):
133
+ digits = re.sub(r"\D", "", m.group(0))
134
+ if len(digits) >= 16 and _luhn_ok(digits):
135
+ hits.append(Hit("finance_detail", "luhn", _mask(digits)))
136
+ break
137
+ return tuple(hits)
138
+
139
+
140
+ @dataclass
141
+ class Decision:
142
+ allowed: bool
143
+ reason: str
144
+ mode: str
145
+ categories: tuple = ()
146
+ hits: tuple = ()
147
+ pending_id: str = ""
148
+
149
+ @property
150
+ def is_pending(self) -> bool:
151
+ return bool(self.pending_id)
152
+
153
+ def to_dict(self) -> dict:
154
+ return {
155
+ "allowed": self.allowed, "reason": self.reason, "mode": self.mode,
156
+ "categories": list(self.categories),
157
+ "hits": [h.to_dict() for h in self.hits],
158
+ "pending_id": self.pending_id,
159
+ }
160
+
161
+
162
+ class PendingStore:
163
+ """待确认的高敏感写入。落盘是因为写入可能跨进程/重启发生,内存队列会静默丢内容。"""
164
+
165
+ def __init__(self, path: str | Path):
166
+ self.path = Path(path)
167
+ self._lock = threading.RLock()
168
+
169
+ def _read(self) -> list:
170
+ try:
171
+ data = json.loads(self.path.read_text(encoding="utf-8"))
172
+ except Exception:
173
+ return []
174
+ return data if isinstance(data, list) else []
175
+
176
+ def _write(self, items: list) -> None:
177
+ self.path.parent.mkdir(parents=True, exist_ok=True)
178
+ tmp = self.path.with_suffix(".tmp")
179
+ tmp.write_text(json.dumps(items, ensure_ascii=False, indent=1), encoding="utf-8")
180
+ os.replace(tmp, self.path)
181
+
182
+ def add(self, entry: dict) -> str:
183
+ with self._lock:
184
+ items = self._read()
185
+ pid = uuid.uuid4().hex[:12]
186
+ items.append({"id": pid, **entry})
187
+ self._write(items[-MAX_PENDING:])
188
+ return pid
189
+
190
+ def list(self) -> list:
191
+ with self._lock:
192
+ return self._read()
193
+
194
+ def pop(self, pid: str) -> dict | None:
195
+ with self._lock:
196
+ items = self._read()
197
+ kept = [it for it in items if it.get("id") != pid]
198
+ found = next((it for it in items if it.get("id") == pid), None)
199
+ if found is not None:
200
+ self._write(kept)
201
+ return found
202
+
203
+
204
+ class SensitiveWritePolicy:
205
+ """判定「这条内容能否直接写入长期记忆」。纯函数式判定 + 挂载式装配(同 shared_write)。"""
206
+
207
+ def __init__(self, mode: str | None = None,
208
+ classifier: SensitivityClassifier | None = None,
209
+ pending_path: str | Path | None = None):
210
+ self.mode = self._resolve_mode(mode)
211
+ self.classifier = classifier or SensitivityClassifier()
212
+ self.pending = PendingStore(pending_path or _default_pending_path())
213
+
214
+ @staticmethod
215
+ def _resolve_mode(mode: str | None = None) -> str:
216
+ m = (mode or os.environ.get(MODE_ENV, "") or "audit").strip().lower()
217
+ return m if m in VALID_MODES else "audit"
218
+
219
+ def evaluate(self, content: str, **entry: Any) -> Decision:
220
+ hits = self.classifier.classify(content)
221
+ if not hits:
222
+ return Decision(True, "无高敏感特征", self.mode)
223
+ cats = tuple(dict.fromkeys(h.category for h in hits))
224
+ if self.mode == "enforce":
225
+ return Decision(False, "高敏感内容需用户同意后才能写入长期记忆",
226
+ self.mode, cats, hits)
227
+ label = "audit 不拦截" if self.mode == "audit" else "warn 不拦截"
228
+ return Decision(True, f"高敏感特征({label})", self.mode, cats, hits)
229
+
230
+ def stash(self, content: str, entry: dict, decision: Decision) -> Decision:
231
+ """enforce 下把被拦的写入转成待确认项,返回带 pending_id 的 Decision。"""
232
+ pid = self.pending.add({**entry, "content": str(content or ""),
233
+ "categories": list(decision.categories)})
234
+ return Decision(decision.allowed, decision.reason, decision.mode,
235
+ decision.categories, decision.hits, pid)
236
+
237
+
238
+ def _default_pending_path() -> Path:
239
+ root = os.environ.get("FOXCLAW_DATA_DIR") or "./foxclaw_data"
240
+ return Path(root) / PENDING_FILE
241
+
242
+
243
+ def guard_sensitive_write(backend, agent_id: str | None, content: str,
244
+ memory_type: str = "concept", **entry: Any) -> Decision | None:
245
+ """记忆落库前的统一闸门。未挂策略时返回 None(向后兼容,与 guard_shared_write 一致)。
246
+
247
+ enforce 且命中高敏感 → 返回 allowed=False 的 Decision,调用方**必须**据此跳过落库;
248
+ 被拦内容此时已进入待确认队列,不会丢。
249
+ """
250
+ policy = getattr(backend, "sensitive_write_policy", None)
251
+ if policy is None:
252
+ return None
253
+
254
+ decision = policy.evaluate(content, **entry)
255
+ spine = getattr(backend, "sensitive_write_spine", None)
256
+ if spine is not None:
257
+ try:
258
+ spine.publish("memory.sensitive", {
259
+ "agent_id": agent_id or "",
260
+ "type": memory_type,
261
+ "allowed": decision.allowed,
262
+ "mode": decision.mode,
263
+ "categories": list(decision.categories),
264
+ "hits": [_mask(h.sample) for h in decision.hits],
265
+ }, source=agent_id or "memory")
266
+ except Exception:
267
+ pass
268
+
269
+ if not decision.allowed:
270
+ if policy.mode == "enforce":
271
+ return policy.stash(content, {"agent_id": agent_id or "",
272
+ "type": memory_type}, decision)
273
+ if policy.mode == "warn":
274
+ print(f"[Foxclaw] ⚠ 高敏感记忆写入(未拦截): agent={agent_id!r} "
275
+ f"categories={list(decision.categories)}")
276
+ return decision
277
+
278
+
279
+ def attach(backend, policy: SensitiveWritePolicy | None = None, spine=None):
280
+ """挂到记忆后端。与 shared_write.attach 同因:装配顺序上脊柱晚于 memory_adapter。"""
281
+ backend.sensitive_write_policy = policy or SensitiveWritePolicy()
282
+ backend.sensitive_write_spine = spine
283
+ return backend.sensitive_write_policy
@@ -1053,6 +1053,7 @@ function renderConflicts(){
1053
1053
  <div class="cc-claim-conf">置信度 ${(b.confidence*100).toFixed(0)}%</div></div>
1054
1054
  <div class="cc-resolve">
1055
1055
  <button class="btn btn-sm btn-compile" ${pick?'':'disabled'} onclick="resolveConflict('${c.conflict_id}')">采纳所选观点</button>
1056
+ <button class="btn btn-sm btn-ghost" onclick="dismissConflict('${c.conflict_id}')" title="两者并不互斥:关闭卡片,双方观点都保留">误判</button>
1056
1057
  </div>
1057
1058
  </div>`;
1058
1059
  }).join('');
@@ -1090,6 +1091,23 @@ async function resolveConflict(cid){
1090
1091
  }catch(e){ toast('裁决失败','err'); }
1091
1092
  }
1092
1093
 
1094
+ async function dismissConflict(cid){
1095
+ if(client.demo){
1096
+ const c=state.conflicts.find(x=>x.conflict_id===cid);
1097
+ if(c) c.status='resolved';
1098
+ delete state.conflictPick[cid];
1099
+ toast('已按误判关闭,双方观点都保留','ok');
1100
+ renderConflicts();
1101
+ return;
1102
+ }
1103
+ try{
1104
+ await client._post(`/conflicts/${cid}/dismiss`,{note:'UI 误判关闭',resolved_by:'ui'});
1105
+ toast('已按误判关闭,双方观点都保留','ok');
1106
+ delete state.conflictPick[cid];
1107
+ await loadData();
1108
+ }catch(e){ toast('关闭失败','err'); }
1109
+ }
1110
+
1093
1111
  let searchTimer;
1094
1112
  async function doSearch(q){
1095
1113
  if(!q.trim()){ if(window.planet) window.planet.setSelected(null); return; } // 清空搜索→恢复默认视图(不重建星球)