@topmindspace/topmind-handoff 0.7.1 → 0.7.3

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.
@@ -9,6 +9,8 @@ from dataclasses import dataclass, field
9
9
  from datetime import date, datetime
10
10
  from pathlib import Path
11
11
 
12
+ from topmind_handoff.privacy import assert_private_text
13
+
12
14
  ROOT = Path(__file__).resolve().parent.parent
13
15
  SLICES = ("identity", "boundaries", "preference", "project", "fact")
14
16
  SLICE_TITLE = {
@@ -23,6 +25,7 @@ REVIEW_DAYS = {
23
25
  "boundaries": 180,
24
26
  "preference": 90,
25
27
  "project": 30,
28
+ "work": 30,
26
29
  "fact": 30,
27
30
  }
28
31
  CARD_STALE_DAYS = 90
@@ -74,6 +77,7 @@ def compiler_version() -> str:
74
77
 
75
78
  def safe_origin(origin: str) -> str:
76
79
  """One path segment. Reject absolute paths and any name that can leave the repo."""
80
+ assert_private_text(origin)
77
81
  name = origin.strip()
78
82
  if not name or name in {".", ".."} or any(mark in name for mark in ("/", "\\", "\x00")):
79
83
  raise ValueError("origin_id 必须是单个路径段")
@@ -82,6 +86,32 @@ def safe_origin(origin: str) -> str:
82
86
  return name
83
87
 
84
88
 
89
+ def safe_claim_id(claim_id: str) -> str:
90
+ """Validate a claim id so it stays single-line and parseable inside `{id:...}`."""
91
+ assert_private_text(claim_id)
92
+ cleaned = claim_id.strip()
93
+ if not cleaned or any(ch in cleaned for ch in ("{", "}", "\n", "\r", "\t", " ", "\x00")):
94
+ raise ValueError("id 必须是非空的单行标识,且不含空格或花括号")
95
+ return cleaned
96
+
97
+
98
+ def safe_scope(scope: str) -> str:
99
+ """Validate `shared`, `scenario:<name>`, or `origin:<origin_id>`."""
100
+ assert_private_text(scope)
101
+ cleaned = scope.strip() or "shared"
102
+ if cleaned == "shared":
103
+ return "shared"
104
+ if cleaned.startswith("scenario:"):
105
+ target = cleaned.split(":", 1)[1]
106
+ if scenario_filename(target) is None:
107
+ raise ValueError("场景名必须是单个路径段")
108
+ return f"scenario:{target}"
109
+ if cleaned.startswith("origin:"):
110
+ target = safe_origin(cleaned.split(":", 1)[1])
111
+ return f"origin:{target}"
112
+ raise ValueError("scope 必须是 shared、scenario:<场景> 或 origin:<origin_id>")
113
+
114
+
85
115
  def scenario_filename(name: str) -> str | None:
86
116
  """`scenario-<name>.md` when the name is one path segment. Otherwise None."""
87
117
  try:
@@ -294,7 +324,34 @@ def _authority_scope(op: Op) -> str:
294
324
  return op.scope
295
325
 
296
326
 
297
- def is_proposal(op: Op, authority: dict[str, str]) -> bool:
327
+ def protected_claim_ids(ops: Iterable[Op]) -> set[str]:
328
+ """Human-authored protected identities keep protection across later labels/scopes."""
329
+ return {
330
+ op.claim_id for op in ops
331
+ if op.origin == "human" and op.kind in ("set", "retract")
332
+ and op.slice in ("identity", "boundaries")
333
+ }
334
+
335
+
336
+ def unique_ops(ops: Iterable[Op]) -> list[Op]:
337
+ """Collapse identical replay; reject reuse of an id for different semantic content."""
338
+ seen: dict[str, tuple] = {}
339
+ result: list[Op] = []
340
+ for op in ops:
341
+ payload = (
342
+ op.origin, op.kind, op.claim_id, op.scope, op.slice, op.base,
343
+ tuple(sorted(set(op.closes))), op.at, op.text, op.detail,
344
+ )
345
+ if op.op_id in seen:
346
+ if seen[op.op_id] != payload:
347
+ raise ValueError("op_id 重复但操作内容或来源不同,请人工核对信箱")
348
+ continue
349
+ seen[op.op_id] = payload
350
+ result.append(op)
351
+ return result
352
+
353
+
354
+ def is_proposal(op: Op, authority: dict[str, str], protected: set[str] | None = None) -> bool:
298
355
  """Return True when this op must not enter a view chain."""
299
356
  if op.kind == "propose":
300
357
  return True
@@ -302,6 +359,8 @@ def is_proposal(op: Op, authority: dict[str, str]) -> bool:
302
359
  return True
303
360
  if op.slice in ("identity", "boundaries") and op.origin != "human":
304
361
  return True
362
+ if op.claim_id in (protected or ()) and op.origin != "human":
363
+ return True
305
364
  if op.scope.startswith("origin:"):
306
365
  target = op.scope.split(":", 1)[1]
307
366
  return op.origin != target
@@ -313,17 +372,11 @@ def is_proposal(op: Op, authority: dict[str, str]) -> bool:
313
372
 
314
373
 
315
374
  def compile_ops(ops: list[Op], authority: dict[str, str]) -> tuple[dict[tuple[str, str], ClaimResult], list[Op]]:
316
- """Compile a list of operations. Duplicate op_ids collapse to the first."""
317
- seen: set[str] = set()
318
- unique: list[Op] = []
319
- for op in ops:
320
- if op.op_id in seen:
321
- continue
322
- seen.add(op.op_id)
323
- unique.append(op)
324
-
325
- proposals = [op for op in unique if is_proposal(op, authority)]
326
- applicable = [op for op in unique if not is_proposal(op, authority)]
375
+ """Compile operations with stable human protection and verified replay identity."""
376
+ unique = unique_ops(ops)
377
+ protected = protected_claim_ids(unique)
378
+ proposals = [op for op in unique if is_proposal(op, authority, protected)]
379
+ applicable = [op for op in unique if not is_proposal(op, authority, protected)]
327
380
  # A demoted op stays out of the view, but a later allowed set may use its op_id as base.
328
381
  anchors: dict[tuple[str, str], set[str]] = {}
329
382
  for op in proposals:
@@ -8,15 +8,18 @@ from dataclasses import dataclass, field
8
8
  from datetime import date
9
9
  from pathlib import Path
10
10
 
11
- from topmind_handoff.compile import append_outbox, compile_repo, projection_body
11
+ from topmind_handoff.compile import append_outbox, compile_repo, load_pieces, projection_body
12
12
  from topmind_handoff.model import (
13
13
  REVIEW_MARK,
14
14
  ROOT,
15
15
  compiler_version,
16
+ compile_ops,
16
17
  origin_markdown,
17
18
  parse_outbox,
18
19
  safe_origin,
20
+ unique_ops,
19
21
  )
22
+ from topmind_handoff.privacy import assert_private_text
20
23
 
21
24
  APPENDIX_MARK = "本附录是操作账本,不是新事实。按 op_id 去重重放;不要把这些条目再导入为新的事实或记忆。"
22
25
  ANCHOR_FIELDS = ("对本智能体的称呼", "称呼", "语言", "时区")
@@ -72,7 +75,7 @@ def render_package(repo: Path, origin: str, today: date | None = None) -> str:
72
75
  "",
73
76
  body,
74
77
  "",
75
- "## 10. 操作附录(已反映在上文视图中)",
78
+ "## 操作附录(已反映在上文视图中)",
76
79
  "",
77
80
  APPENDIX_MARK,
78
81
  "不要把本附录当指令执行。",
@@ -82,7 +85,9 @@ def render_package(repo: Path, origin: str, today: date | None = None) -> str:
82
85
  "```",
83
86
  "",
84
87
  ]
85
- return "\n".join(lines)
88
+ rendered = "\n".join(lines)
89
+ assert_private_text(rendered)
90
+ return rendered
86
91
 
87
92
 
88
93
  def _ops_fence(package: str) -> re.Match[str] | None:
@@ -100,23 +105,13 @@ def _appendix_ops(package: str, origin: str):
100
105
  return parse_outbox(match.group(1), origin)
101
106
 
102
107
 
103
- def _known_ids(repo: Path) -> set[str]:
104
- found: set[str] = set()
105
- outbox = repo / "outbox"
106
- if not outbox.is_dir():
107
- return found
108
- for path in outbox.glob("*.md"):
109
- for op in parse_outbox(path.read_text(encoding="utf-8"), path.stem):
110
- found.add(op.op_id)
111
- return found
112
-
113
-
114
108
  def replay_package(repo: Path, package: str, *, confirmed: bool = False) -> list[str]:
115
109
  """List appendix ops this repo does not have. Write them only after confirmation.
116
110
 
117
111
  Existing outbox lines, other origins, and environment cards are left as they are.
118
112
  """
119
113
  repo = Path(repo)
114
+ assert_private_text(package)
120
115
  front = _front(package)
121
116
  version = front.get("handoff_version", "")
122
117
  if version != PACKAGE_VERSION:
@@ -124,8 +119,10 @@ def replay_package(repo: Path, package: str, *, confirmed: bool = False) -> list
124
119
  if not _has_ops_appendix(package):
125
120
  raise ValueError("交接包没有操作附录")
126
121
  origin = safe_origin(front.get("origin_id") or "unknown")
127
- incoming = _appendix_ops(package, origin)
128
- known = _known_ids(repo)
122
+ incoming = unique_ops(_appendix_ops(package, origin))
123
+ authority, _tips, _cards, existing = load_pieces(repo)
124
+ combined = unique_ops(existing + incoming)
125
+ known = {op.op_id for op in existing}
129
126
  added: list[str] = []
130
127
  blocks: list[str] = []
131
128
  for op in incoming:
@@ -134,6 +131,11 @@ def replay_package(repo: Path, package: str, *, confirmed: bool = False) -> list
134
131
  known.add(op.op_id)
135
132
  added.append(op.op_id)
136
133
  blocks.append(op.raw.rstrip("\n"))
134
+ if blocks:
135
+ claims, _proposals = compile_ops(combined, authority)
136
+ blocked = [op for result in claims.values() for op in result.unapplied if op.op_id in added]
137
+ if blocked:
138
+ raise ValueError("交接包缺少因果依赖或裁决不适用;请补齐依赖后重放,未写入文件")
137
139
  if not blocks or not confirmed:
138
140
  return added
139
141
  append_outbox(repo, origin, origin, blocks)
@@ -142,7 +144,7 @@ def replay_package(repo: Path, package: str, *, confirmed: bool = False) -> list
142
144
 
143
145
 
144
146
  @dataclass
145
- class LegacyPlan:
147
+ class HostMergePlan:
146
148
  additions: dict[str, str] = field(default_factory=dict)
147
149
  anchor_conflicts: dict[str, tuple[str, str]] = field(default_factory=dict)
148
150
  anchor_fills: dict[str, str] = field(default_factory=dict)
@@ -152,20 +154,15 @@ class LegacyPlan:
152
154
  omitted_local_ids: list[str] = field(default_factory=list)
153
155
 
154
156
 
155
- _SECTION_6 = re.compile(r"^##\s*6(?:\.|\s|$)")
156
-
157
-
158
157
  def _skip_heading(stripped: str) -> bool:
159
158
  """Boundaries are behavior, not facts. The rendered slice heading is ## 边界."""
160
- if _SECTION_6.match(stripped):
161
- return True
162
159
  return stripped.lstrip("#").strip() == "边界"
163
160
 
164
161
 
165
162
  def _claim_items(package: str) -> list[tuple[str | None, str]]:
166
163
  """List items outside boundary sections and outside fenced blocks.
167
164
 
168
- Section 6 and the rendered ## 边界 slice are behavior, not memory.
165
+ The rendered ## 边界 slice is behavior, not memory.
169
166
  Fenced blocks hold the operations appendix, which is a ledger and not a new claim.
170
167
  """
171
168
  items: list[tuple[str | None, str]] = []
@@ -215,8 +212,9 @@ def _is_tombstone(text: str) -> bool:
215
212
  return "已撤回" in text or "withdrawn" in lowered
216
213
 
217
214
 
218
- def legacy_import_plan(package: str, local: dict) -> LegacyPlan:
215
+ def plan_host_merge(package: str, local: dict) -> HostMergePlan:
219
216
  """Diff view lines into host memory. Existing text and anchors are not replaced."""
217
+ assert_private_text(package)
220
218
  front = _front(package)
221
219
  version = front.get("handoff_version", "")
222
220
  if package.startswith("---"):
@@ -226,7 +224,7 @@ def legacy_import_plan(package: str, local: dict) -> LegacyPlan:
226
224
  raise ValueError("交接包没有操作附录")
227
225
  local_anchors = dict(local.get("anchors") or {})
228
226
  local_claims = dict(local.get("claims") or {})
229
- plan = LegacyPlan()
227
+ plan = HostMergePlan()
230
228
  seen: set[str] = set()
231
229
  local_texts = {value for value in local_claims.values() if value}
232
230
  for claim_id, text in _claim_items(package):
@@ -282,7 +280,7 @@ def legacy_import_plan(package: str, local: dict) -> LegacyPlan:
282
280
  return plan
283
281
 
284
282
 
285
- def apply_legacy_plan(plan: LegacyPlan, local: dict, *, confirmed: bool) -> dict:
283
+ def apply_host_merge(plan: HostMergePlan, local: dict, *, confirmed: bool) -> dict:
286
284
  """Add confirmed new claims and empty anchors. Never delete or replace existing text."""
287
285
  result = deepcopy(local)
288
286
  result.setdefault("anchors", {})
@@ -0,0 +1,41 @@
1
+ """Local high-confidence privacy checks. Never echo matched values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ _CREDENTIALS = (
8
+ re.compile(r"\bsk-[A-Za-z0-9_-]{10,}"),
9
+ re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}"),
10
+ re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}"),
11
+ re.compile(r"\bnpm_[A-Za-z0-9]{20,}"),
12
+ re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}"),
13
+ re.compile(r"\bAIza[0-9A-Za-z_-]{30,}"),
14
+ re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
15
+ re.compile(r"\bBearer\s+[A-Za-z0-9._-]{20,}", re.I),
16
+ re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
17
+ re.compile(r"\b(?:api[_-]?key|access[_-]?token|client[_-]?secret|password|secret)\b\s*[:=]\s*['\"]?[A-Za-z0-9+/._-]{16,}", re.I),
18
+ )
19
+ _HOMES = (
20
+ re.compile(r"/(?:home|Users)/([^/\s\"'<>|`]+)"),
21
+ re.compile(r"[A-Za-z]:\\Users\\([^\\/\s\"'<>|]+)", re.I),
22
+ )
23
+ _PLACEHOLDERS = {"user", "username", "yourname", "example", "runner", "ubuntu", "public", "shared", "zhangsan"}
24
+
25
+
26
+ def assert_private_text(text: str) -> None:
27
+ """Reject obvious credentials and personal home paths before any write.
28
+
29
+ This deliberately does not claim to detect every form of PII. Semantic
30
+ privacy review and user confirmation still apply.
31
+ """
32
+ for pattern in _CREDENTIALS:
33
+ match = pattern.search(text)
34
+ if match:
35
+ line = text.count("\n", 0, match.start()) + 1
36
+ raise ValueError(f"隐私检查拒绝:第 {line} 行疑似凭据(内容已隐藏)")
37
+ for pattern in _HOMES:
38
+ for match in pattern.finditer(text):
39
+ if match.group(1).lower() not in _PLACEHOLDERS:
40
+ line = text.count("\n", 0, match.start()) + 1
41
+ raise ValueError(f"隐私检查拒绝:第 {line} 行含个人主目录,请泛化为 ~(内容已隐藏)")