shopping-cli 3.2.0__tar.gz → 3.2.1__tar.gz

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 (110) hide show
  1. {shopping_cli-3.2.0/shopping_cli.egg-info → shopping_cli-3.2.1}/PKG-INFO +1 -1
  2. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/pyproject.toml +1 -1
  3. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/__init__.py +1 -1
  4. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/handlers/negotiation.py +13 -8
  5. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/contracts/kiwi-contracts.lock.json +1 -1
  6. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/core/channels.py +15 -1
  7. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/data_sources/csv_excel_source.py +121 -14
  8. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/db/migrations.py +25 -1
  9. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/db/models.py +11 -0
  10. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/services/negotiation.py +57 -2
  11. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/services/tokens.py +1 -1
  12. {shopping_cli-3.2.0 → shopping_cli-3.2.1/shopping_cli.egg-info}/PKG-INFO +1 -1
  13. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/LICENSE +0 -0
  14. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/MANIFEST.in +0 -0
  15. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/README.md +0 -0
  16. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/setup.cfg +0 -0
  17. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/agents/__init__.py +0 -0
  18. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/agents/agent_cli.py +0 -0
  19. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/agents/buyer_cli.py +0 -0
  20. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/agents/merchant_agent.py +0 -0
  21. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/agents/merchant_daemon.py +0 -0
  22. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/agents/merchant_state_helpers.py +0 -0
  23. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/agents/tools.py +0 -0
  24. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/__init__.py +0 -0
  25. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/app.py +0 -0
  26. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/auth.py +0 -0
  27. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/error_handlers.py +0 -0
  28. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/error_response.py +0 -0
  29. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/fallback_asgi.py +0 -0
  30. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/fastapi_routes.py +0 -0
  31. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/handlers/__init__.py +0 -0
  32. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/handlers/agents.py +0 -0
  33. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/handlers/audit.py +0 -0
  34. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/handlers/buyer.py +0 -0
  35. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/handlers/catalog.py +0 -0
  36. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/handlers/common.py +0 -0
  37. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/handlers/conversations.py +0 -0
  38. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/handlers/erp.py +0 -0
  39. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/handlers/human_review.py +0 -0
  40. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/handlers/listings_projection.py +0 -0
  41. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/idempotency.py +0 -0
  42. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/limits.py +0 -0
  43. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/request_dispatch.py +0 -0
  44. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/request_limits.py +0 -0
  45. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/route_matching.py +0 -0
  46. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/route_registry.py +0 -0
  47. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/route_table.py +0 -0
  48. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/routes_agents.py +0 -0
  49. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/routes_conversations.py +0 -0
  50. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/routes_marketplace.py +0 -0
  51. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/routes_merchants.py +0 -0
  52. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/api/server.py +0 -0
  53. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/cli.py +0 -0
  54. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/cli_adapter_commands.py +0 -0
  55. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/cli_agent_commands.py +0 -0
  56. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/cli_buyer_commands.py +0 -0
  57. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/cli_catalog_commands.py +0 -0
  58. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/cli_common.py +0 -0
  59. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/cli_conversation_commands.py +0 -0
  60. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/cli_erp_commands.py +0 -0
  61. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/cli_listing_commands.py +0 -0
  62. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/commerce/__init__.py +0 -0
  63. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/commerce/adapters.py +0 -0
  64. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/commerce/data_source.py +0 -0
  65. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/config.py +0 -0
  66. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/contracts/shopping.negotiation/0.1/capabilities.schema.json +0 -0
  67. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/contracts/shopping.negotiation/0.1/decision.schema.json +0 -0
  68. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/contracts/shopping.negotiation/0.1/policy-result.schema.json +0 -0
  69. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/contracts/shopping.negotiation/0.1/snapshot.schema.json +0 -0
  70. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/core/__init__.py +0 -0
  71. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/core/catalog.py +0 -0
  72. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/core/catalog_scoring.py +0 -0
  73. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/core/catalog_text.py +0 -0
  74. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/core/catalog_views.py +0 -0
  75. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/core/conversations.py +0 -0
  76. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/core/delivery.py +0 -0
  77. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/core/errors.py +0 -0
  78. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/core/harness.py +0 -0
  79. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/core/limits.py +0 -0
  80. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/core/negotiation.py +0 -0
  81. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/core/negotiation_contracts.py +0 -0
  82. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/core/policies.py +0 -0
  83. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/core/risk.py +0 -0
  84. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/core/tokens.py +0 -0
  85. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/data_sources/__init__.py +0 -0
  86. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/data_sources/adapter.py +0 -0
  87. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/data_sources/erp_source.py +0 -0
  88. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/db/__init__.py +0 -0
  89. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/db/provenance.py +0 -0
  90. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/db/session.py +0 -0
  91. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/http_client.py +0 -0
  92. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/listings/__init__.py +0 -0
  93. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/listings/projection.py +0 -0
  94. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/services/__init__.py +0 -0
  95. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/services/agents.py +0 -0
  96. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/services/audit.py +0 -0
  97. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/services/buyer_bootstrap.py +0 -0
  98. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/services/conversations.py +0 -0
  99. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/services/human_review.py +0 -0
  100. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/services/negotiation_gates.py +0 -0
  101. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/services/negotiation_message.py +0 -0
  102. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/services/negotiation_policy_helpers.py +0 -0
  103. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/services/negotiation_policy_result.py +0 -0
  104. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/services/negotiation_snapshot.py +0 -0
  105. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli/services/negotiation_snapshot_projection.py +0 -0
  106. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli.egg-info/SOURCES.txt +0 -0
  107. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli.egg-info/dependency_links.txt +0 -0
  108. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli.egg-info/entry_points.txt +0 -0
  109. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli.egg-info/requires.txt +0 -0
  110. {shopping_cli-3.2.0 → shopping_cli-3.2.1}/shopping_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shopping-cli
3
- Version: 3.2.0
3
+ Version: 3.2.1
4
4
  Summary: Standalone local-commerce AI consultation marketplace runtime
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "shopping-cli"
7
- version = "3.2.0"
7
+ version = "3.2.1"
8
8
  description = "Standalone local-commerce AI consultation marketplace runtime"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """Standalone shopping-cli consultation runtime."""
2
2
 
3
- VERSION = "3.2.0"
3
+ VERSION = "3.2.1"
@@ -20,9 +20,13 @@ from shopping_cli.db.session import db_session
20
20
  from shopping_cli.services import negotiation as negotiation_service
21
21
 
22
22
  # 审查 S-M2:merchant/agent/negotiation/human-review 端点此前均无限流——已泄露/
23
- # 低熵 token 的滥用无节流。对磋商决策提交(写路径,追加磋商消息)加 per-owner
24
- # 固定窗口限流:进程内(单进程部署前提,与 ERP sync 限流同款)。env 可覆盖
25
- # (调用时读取,便于测试注入)。
23
+ # 低熵 token 的滥用无节流。对磋商决策提交(写路径,追加磋商消息)加固定窗口
24
+ # 限流:进程内(单进程部署前提,与 ERP sync 限流同款)。env 可覆盖(调用时
25
+ # 读取,便于测试注入)。
26
+ # 审查 S-M2 补强:限流键从 per-owner(buyer 的 owner_id 客户端声明,轮换
27
+ # buyer_id 即绕过)改为 per-token(token_hash,服务端派生)——同凭证无论声明
28
+ # 什么 buyer_id 都共享一个桶;轮换新 token 受 S-M1 bootstrap 全局桶(铸造速率)
29
+ # 封顶。merchant/agent 单 token 部署下语义不变。
26
30
  _NEGOTIATION_DECISION_WINDOW_SECONDS = 60
27
31
  _DECISION_RATE_LIMIT_DEFAULT_PER_MINUTE = 600
28
32
  _decision_buckets: dict[str, tuple[float, int]] = {}
@@ -38,18 +42,18 @@ def _decision_rate_limit_per_minute() -> int:
38
42
  return value if value > 0 else _DECISION_RATE_LIMIT_DEFAULT_PER_MINUTE
39
43
 
40
44
 
41
- def _enforce_decision_rate_limit(owner_id: str) -> None:
45
+ def _enforce_decision_rate_limit(credential_key: str) -> None:
42
46
  limit = _decision_rate_limit_per_minute()
43
47
  now = time.monotonic()
44
48
  with _decision_buckets_lock:
45
- window_start, count = _decision_buckets.get(owner_id, (0.0, 0))
49
+ window_start, count = _decision_buckets.get(credential_key, (0.0, 0))
46
50
  if now - window_start >= _NEGOTIATION_DECISION_WINDOW_SECONDS:
47
51
  window_start, count = now, 0
48
52
  if count >= limit:
49
53
  raise RateLimitError(
50
54
  f"negotiation decision rate limit exceeded ({limit}/minute)"
51
55
  )
52
- _decision_buckets[owner_id] = (window_start, count + 1)
56
+ _decision_buckets[credential_key] = (window_start, count + 1)
53
57
 
54
58
 
55
59
  def _actor(conn: Any, payload: dict[str, Any]) -> negotiation_service.NegotiationActor:
@@ -96,8 +100,9 @@ def get_snapshot(db_path: str | Path, payload: dict[str, Any], query: dict[str,
96
100
  def submit_decision(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
97
101
  with db_session(db_path) as conn:
98
102
  actor = _actor(conn, payload)
99
- # 审查 S-M2:per-owner 固定窗口限流(决策提交=磋商消息追加,滥用最直接)。
100
- _enforce_decision_rate_limit(actor.owner_id)
103
+ # 审查 S-M2:固定窗口限流(决策提交=磋商消息追加,滥用最直接)。键用
104
+ # 服务端派生的 token_hash(per-token),空值兜底 owner_id。
105
+ _enforce_decision_rate_limit(actor.token_hash or actor.owner_id)
101
106
  policy_result = negotiation_service.submit_decision(
102
107
  conn,
103
108
  actor,
@@ -2,6 +2,6 @@
2
2
  "lock_version": 1,
3
3
  "source_repository": "harrylabsj/kiwi",
4
4
  "source_commit": "009bc25cdae2668c8fd19ccedcc4e0c64a34f6be",
5
- "bundle_sha256": "2dbbc52a1113dc5c05dc544802571ee188b44e1567d6bfb0cd8ccd87ea7f4998",
5
+ "bundle_sha256": "2350ecaacebb791cd980cc2886511359e356378a6f18cdecc1ac6331c31b0e70",
6
6
  "contracts_manifest": "contracts/manifest.json"
7
7
  }
@@ -168,7 +168,7 @@ def _begin_channel_ingress(
168
168
  return _existing_ingress_response(conn, row, buyer_id, channel)
169
169
  # stale_at 非空 → 落 INSERT ON CONFLICT 覆盖旧行(重处理幂等)
170
170
  try:
171
- conn.execute(
171
+ cursor = conn.execute(
172
172
  """
173
173
  insert into channel_message_ingresses(
174
174
  channel, external_user_id, external_message_id, status,
@@ -181,6 +181,7 @@ def _begin_channel_ingress(
181
181
  message_id = excluded.message_id,
182
182
  stale_at = excluded.stale_at,
183
183
  updated_at = excluded.updated_at
184
+ where channel_message_ingresses.stale_at != ''
184
185
  """,
185
186
  (channel, external_user_id, external_message_id, PROCESSING_STATUS, now, now),
186
187
  )
@@ -195,6 +196,19 @@ def _begin_channel_ingress(
195
196
  if row is not None:
196
197
  return _existing_ingress_response(conn, row, buyer_id, channel)
197
198
  raise
199
+ if cursor.rowcount == 0:
200
+ # 并发重复:另一请求已先落 fresh processing 行(stale_at 为空),DO UPDATE
201
+ # 被 WHERE 跳过、rowcount=0 → 我们未抢到写权,回查走冲突/幂等分支。
202
+ row = conn.execute(
203
+ """
204
+ select * from channel_message_ingresses
205
+ where channel = ? and external_user_id = ? and external_message_id = ?
206
+ """,
207
+ (channel, external_user_id, external_message_id),
208
+ ).fetchone()
209
+ if row is not None:
210
+ return _existing_ingress_response(conn, row, buyer_id, channel)
211
+ raise
198
212
  return None
199
213
 
200
214
 
@@ -23,7 +23,7 @@ import math
23
23
  import sqlite3
24
24
  import zipfile
25
25
  import xml.etree.ElementTree as ET
26
- from dataclasses import dataclass, field
26
+ from dataclasses import dataclass
27
27
  from datetime import datetime, timedelta, timezone
28
28
  from decimal import Decimal, InvalidOperation
29
29
  from pathlib import Path
@@ -45,6 +45,17 @@ SOURCE_CSV_EXCEL = "csv_excel"
45
45
 
46
46
  _XLSX_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
47
47
 
48
+ # Import limits are deliberately conservative: CSV/XLSX are operator-supplied
49
+ # files, so parsing must not turn an accidental or malicious workbook into an
50
+ # unbounded memory/CPU allocation (zip bombs, giant shared strings, or millions
51
+ # of rows). These are wire/data-source limits, not database limits.
52
+ MAX_IMPORT_FILE_BYTES = 10 * 1024 * 1024
53
+ MAX_IMPORT_ROWS = 100_000
54
+ MAX_IMPORT_COLUMNS = 256
55
+ MAX_IMPORT_CELL_CHARS = 16_384
56
+ MAX_XLSX_ENTRIES = 256
57
+ MAX_XLSX_UNCOMPRESSED_BYTES = 50 * 1024 * 1024
58
+
48
59
 
49
60
  @dataclass(frozen=True)
50
61
  class CsvExcelSyncConfig:
@@ -75,6 +86,12 @@ def read_rows(path: str) -> list[dict[str, str]]:
75
86
  p = Path(path)
76
87
  if not p.is_file():
77
88
  raise AdapterError(f"file not found: {path}")
89
+ try:
90
+ size = p.stat().st_size
91
+ except OSError as exc:
92
+ raise AdapterError(f"cannot stat import file: {path}") from exc
93
+ if size > MAX_IMPORT_FILE_BYTES:
94
+ raise AdapterError(f"import file exceeds {MAX_IMPORT_FILE_BYTES} bytes")
78
95
  if p.suffix.lower() == ".xlsx":
79
96
  return _read_xlsx_rows(p)
80
97
  if p.suffix.lower() in {".csv", ".tsv", ""}:
@@ -85,33 +102,97 @@ def read_rows(path: str) -> list[dict[str, str]]:
85
102
  def _read_csv_rows(path: Path) -> list[dict[str, str]]:
86
103
  try:
87
104
  with path.open("r", encoding="utf-8-sig", newline="") as f:
105
+ # csv's parser otherwise accepts arbitrarily large individual fields.
106
+ csv.field_size_limit(MAX_IMPORT_CELL_CHARS)
88
107
  reader = csv.DictReader(f)
89
- rows = [dict(r) for r in reader]
108
+ header = reader.fieldnames
109
+ if header is None:
110
+ return []
111
+ if len(header) > MAX_IMPORT_COLUMNS:
112
+ raise AdapterError(f"CSV has more than {MAX_IMPORT_COLUMNS} columns")
113
+ if any(len(str(name or "")) > MAX_IMPORT_CELL_CHARS for name in header):
114
+ raise AdapterError(f"CSV header exceeds {MAX_IMPORT_CELL_CHARS} characters")
115
+ rows: list[dict[str, str]] = []
116
+ for index, row in enumerate(reader, start=1):
117
+ if index > MAX_IMPORT_ROWS:
118
+ raise AdapterError(f"CSV exceeds {MAX_IMPORT_ROWS} data rows")
119
+ if len(row) > MAX_IMPORT_COLUMNS:
120
+ raise AdapterError(f"CSV row {index} has more than {MAX_IMPORT_COLUMNS} columns")
121
+ normalized: dict[str, str] = {}
122
+ for key, value in row.items():
123
+ if key is None:
124
+ raise AdapterError(f"CSV row {index} has too many columns")
125
+ text = str(value or "")
126
+ if len(text) > MAX_IMPORT_CELL_CHARS:
127
+ raise AdapterError(f"CSV row {index} contains a cell over {MAX_IMPORT_CELL_CHARS} characters")
128
+ normalized[str(key)] = text
129
+ rows.append(normalized)
90
130
  except (UnicodeDecodeError, csv.Error) as exc:
91
131
  raise AdapterError(f"invalid CSV: {exc}") from exc
92
132
  return rows
93
133
 
94
134
 
135
+ def _xlsx_cell_column(ref: str | None, row_index: int, cell_pos: int) -> int:
136
+ """解析单元格 ``r`` 属性(如 ``C2``)→ 0 基列号。
137
+
138
+ Excel 常省略空单元格(留白/样式单元格/合并后空列),按文档顺序对齐会让
139
+ 后续有值单元格左移、静默写坏 price/stock(审查 S-M5)。因此以 ``r`` 属性
140
+ 的列字母定位(A=0 … ZZ=701),缺列由调用方补 ``""``。无 ``r`` 属性
141
+ (少见)回退文档顺序位置。
142
+ """
143
+ if ref:
144
+ letters = "".join(ch for ch in ref if ch.isalpha()).upper()
145
+ if letters:
146
+ col = 0
147
+ for ch in letters:
148
+ col = col * 26 + (ord(ch) - ord("A") + 1)
149
+ if col >= 1:
150
+ return col - 1
151
+ # 无 r / 纯数字引用:回退文档顺序位置(保持既有行为)。
152
+ return cell_pos - 1
153
+
154
+
95
155
  def _xlsx_read_sheet(zf: zipfile.ZipFile, sheet_path: str, shared: list[str]) -> list[list[str]]:
96
156
  root = ET.fromstring(zf.read(sheet_path))
97
157
  ns = _XLSX_NS
98
158
  rows: list[list[str]] = []
99
- for row in root.iter(f"{{{ns}}}row"):
159
+ for row_index, row in enumerate(root.iter(f"{{{ns}}}row"), start=1):
160
+ if row_index > MAX_IMPORT_ROWS:
161
+ raise AdapterError(f"xlsx exceeds {MAX_IMPORT_ROWS} data rows")
100
162
  cells: list[str] = []
101
- for cell in row:
163
+ for cell_pos, cell in enumerate(row, start=1):
164
+ if cell_pos > MAX_IMPORT_COLUMNS:
165
+ raise AdapterError(f"xlsx row {row_index} has more than {MAX_IMPORT_COLUMNS} columns")
166
+ col = _xlsx_cell_column(cell.get("r"), row_index, cell_pos)
167
+ if col >= MAX_IMPORT_COLUMNS:
168
+ raise AdapterError(f"xlsx row {row_index} column {col + 1} exceeds {MAX_IMPORT_COLUMNS} columns")
169
+ # 按 r 属性定位列,跳过的空列补 ""(S-M5:空单元格不得左移错位)。
170
+ if col >= len(cells):
171
+ cells.extend("" for _ in range(col + 1 - len(cells)))
102
172
  t = cell.get("t", "n")
103
173
  v = cell.find(f"{{{ns}}}v")
174
+ value = ""
104
175
  if t == "s" and v is not None:
105
- idx = int(v.text or "0")
106
- cells.append(shared[idx] if idx < len(shared) else "")
176
+ try:
177
+ idx = int(v.text or "0")
178
+ except ValueError as exc:
179
+ raise AdapterError(
180
+ f"xlsx row {row_index} has invalid shared string index {v.text!r}"
181
+ ) from exc
182
+ if idx < 0 or idx >= len(shared):
183
+ raise AdapterError(
184
+ f"xlsx row {row_index} shared string index {idx} out of range"
185
+ )
186
+ value = shared[idx]
107
187
  elif t == "inlineStr":
108
188
  is_el = cell.find(f"{{{ns}}}is")
109
- text = ""
110
189
  if is_el is not None:
111
- text = "".join(tt.text or "" for tt in is_el.iter(f"{{{ns}}}t"))
112
- cells.append(text)
190
+ value = "".join(tt.text or "" for tt in is_el.iter(f"{{{ns}}}t"))
113
191
  elif v is not None:
114
- cells.append(str(v.text or ""))
192
+ value = str(v.text or "")
193
+ if len(value) > MAX_IMPORT_CELL_CHARS:
194
+ raise AdapterError(f"xlsx row {row_index} contains a cell over {MAX_IMPORT_CELL_CHARS} characters")
195
+ cells[col] = value
115
196
  rows.append(cells)
116
197
  return rows
117
198
 
@@ -119,11 +200,22 @@ def _xlsx_read_sheet(zf: zipfile.ZipFile, sheet_path: str, shared: list[str]) ->
119
200
  def _read_xlsx_rows(path: Path) -> list[dict[str, str]]:
120
201
  try:
121
202
  with zipfile.ZipFile(path) as zf:
203
+ infos = zf.infolist()
204
+ if len(infos) > MAX_XLSX_ENTRIES:
205
+ raise AdapterError(f"xlsx has more than {MAX_XLSX_ENTRIES} zip entries")
206
+ uncompressed = sum(info.file_size for info in infos)
207
+ if uncompressed > MAX_XLSX_UNCOMPRESSED_BYTES:
208
+ raise AdapterError(f"xlsx uncompressed content exceeds {MAX_XLSX_UNCOMPRESSED_BYTES} bytes")
122
209
  shared: list[str] = []
123
210
  if "xl/sharedStrings.xml" in zf.namelist():
124
211
  root = ET.fromstring(zf.read("xl/sharedStrings.xml"))
125
212
  for si in root.iter(f"{{{_XLSX_NS}}}si"):
126
- shared.append("".join(t.text or "" for t in si.iter(f"{{{_XLSX_NS}}}t")))
213
+ if len(shared) >= MAX_IMPORT_ROWS * MAX_IMPORT_COLUMNS:
214
+ raise AdapterError("xlsx shared strings exceed the supported cell count")
215
+ value = "".join(t.text or "" for t in si.iter(f"{{{_XLSX_NS}}}t"))
216
+ if len(value) > MAX_IMPORT_CELL_CHARS:
217
+ raise AdapterError(f"xlsx shared string exceeds {MAX_IMPORT_CELL_CHARS} characters")
218
+ shared.append(value)
127
219
  sheet_path = None
128
220
  for name in ("xl/worksheets/sheet1.xml", "xl/worksheets/sheet.xml"):
129
221
  if name in zf.namelist():
@@ -150,6 +242,21 @@ def _read_xlsx_rows(path: Path) -> list[dict[str, str]]:
150
242
  # ---------------------------------------------------------------------------
151
243
 
152
244
 
245
+ _FORMULA_INJECTION_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
246
+
247
+
248
+ def _neutralize_formula(value: str) -> str:
249
+ """防 CSV/Excel 公式注入:以 = + - @ \\t \\r 开头的文本前缀单引号。
250
+
251
+ 审查 L1:导入的文本字段若被下游重新导出为 CSV/Excel 并打开,公式前缀会被
252
+ 执行(=cmd|... / @SUM(...) 等)。前缀 ' 让 Excel 把单元格视为文本,不破坏
253
+ 合法数据(如 "-5% off")。
254
+ """
255
+ if value.startswith(_FORMULA_INJECTION_PREFIXES):
256
+ return "'" + value
257
+ return value
258
+
259
+
153
260
  def _parse_product(raw: dict[str, str], index: int) -> dict[str, Any]:
154
261
  """CSV/Excel 行 → 本地 products 行(sku/title/price/stock 校验,fail-closed)。"""
155
262
  sku = (raw.get("sku") or "").strip()
@@ -180,12 +287,12 @@ def _parse_product(raw: dict[str, str], index: int) -> dict[str, Any]:
180
287
  raise AdapterError(f"row {index}: invalid stock {stock_raw!r}")
181
288
  return {
182
289
  "sku": sku,
183
- "title": title,
290
+ "title": _neutralize_formula(title),
184
291
  "price": price,
185
292
  "stock": stock,
186
293
  "currency": (raw.get("currency") or "").strip() or "CNY",
187
- "category": (raw.get("category") or "").strip(),
188
- "description": (raw.get("description") or "").strip(),
294
+ "category": _neutralize_formula((raw.get("category") or "").strip()),
295
+ "description": _neutralize_formula((raw.get("description") or "").strip()),
189
296
  "merchant_id": (raw.get("merchant_id") or "").strip(),
190
297
  }
191
298
 
@@ -10,7 +10,7 @@ from typing import Callable
10
10
 
11
11
  from shopping_cli.core.tokens import is_sha256_digest, token_digest, token_prefix, token_suffix
12
12
 
13
- CURRENT_SCHEMA_VERSION = 24
13
+ CURRENT_SCHEMA_VERSION = 25
14
14
 
15
15
 
16
16
  @dataclass(frozen=True)
@@ -506,6 +506,29 @@ def migration_023_remove_scheme_a_stub_merchants(conn: sqlite3.Connection) -> No
506
506
  conn.execute("delete from merchants where id = ?", (merchant_id,))
507
507
 
508
508
 
509
+ def migration_025_negotiation_decision_idempotency(conn: sqlite3.Connection) -> None:
510
+ """协商决策幂等账本(审查 H2 2026-08-16)。
511
+
512
+ ``submit_decision`` 此前靠"先扫描 messages 再 append"判断幂等,并发相同
513
+ idempotency_key 的两个请求都能通过预读并各自写入决策消息(违反冻结协议
514
+ "同 key 重放绝不重复")。新增 (conversation_id, agent_id, idempotency_key)
515
+ 唯一表,claim 时由唯一约束兜底并发——与 buyer_request_idempotency 同模式。
516
+ """
517
+ conn.execute(
518
+ """
519
+ create table if not exists negotiation_decision_idempotency (
520
+ conversation_id text not null,
521
+ agent_id text not null,
522
+ idempotency_key text not null,
523
+ message_id integer not null default 0,
524
+ decision_json text not null default '{}',
525
+ created_at text not null,
526
+ primary key (conversation_id, agent_id, idempotency_key)
527
+ )
528
+ """
529
+ )
530
+
531
+
509
532
  MIGRATIONS: tuple[Migration, ...] = (
510
533
  Migration(1, "conversation_next_actor", migration_001_conversation_next_actor),
511
534
  Migration(2, "agent_runtime_columns", migration_002_agent_runtime_columns),
@@ -524,6 +547,7 @@ MIGRATIONS: tuple[Migration, ...] = (
524
547
  Migration(22, "product_handoff_destination", migration_022_product_handoff_destination),
525
548
  Migration(23, "remove_scheme_a_stub_merchants", migration_023_remove_scheme_a_stub_merchants),
526
549
  Migration(24, "product_source_csv_excel", migration_024_product_source_csv_excel),
550
+ Migration(25, "negotiation_decision_idempotency", migration_025_negotiation_decision_idempotency),
527
551
  )
528
552
 
529
553
 
@@ -233,6 +233,17 @@ SCHEMA = [
233
233
  primary key (admin_token_hash, idempotency_key)
234
234
  )
235
235
  """,
236
+ """
237
+ create table if not exists negotiation_decision_idempotency (
238
+ conversation_id text not null,
239
+ agent_id text not null,
240
+ idempotency_key text not null,
241
+ message_id integer not null default 0,
242
+ decision_json text not null default '{}',
243
+ created_at text not null,
244
+ primary key (conversation_id, agent_id, idempotency_key)
245
+ )
246
+ """,
236
247
 
237
248
  ]
238
249
 
@@ -92,10 +92,14 @@ class NegotiationActor:
92
92
  owner_id: str # buyer_id or merchant_id bound to the token
93
93
  agent_id: str # claim identity used in agent_message_processes
94
94
  conversation_id: str = "" # buyer tokens are bound to one conversation
95
+ token_hash: str = "" # 审查 S-M2 补强:token 摘要(服务端派生)——决策限流按
96
+ # 凭证(token)键控而非客户端声明的 owner_id,buyer 轮换 buyer_id 无法用
97
+ # 同一 token 重置限流桶。
95
98
 
96
99
 
97
100
  def require_negotiation_actor(conn: sqlite3.Connection, token: Any) -> NegotiationActor:
98
101
  row = token_service.require_api_token(conn, token, "negotiation token required")
102
+ token_hash = str(row["token_hash"] or "")
99
103
  role = str(row["role"] or "")
100
104
  if role == "buyer":
101
105
  buyer_id = str(row["buyer_id"] or "")
@@ -107,6 +111,7 @@ def require_negotiation_actor(conn: sqlite3.Connection, token: Any) -> Negotiati
107
111
  owner_id=buyer_id,
108
112
  agent_id=protocol.buyer_agent_identity(buyer_id),
109
113
  conversation_id=conversation_id,
114
+ token_hash=token_hash,
110
115
  )
111
116
  if role == "merchant":
112
117
  merchant_id = str(row["merchant_id"] or "")
@@ -116,13 +121,14 @@ def require_negotiation_actor(conn: sqlite3.Connection, token: Any) -> Negotiati
116
121
  role="merchant",
117
122
  owner_id=merchant_id,
118
123
  agent_id=token_service.default_merchant_agent_id(merchant_id),
124
+ token_hash=token_hash,
119
125
  )
120
126
  if role == "agent":
121
127
  merchant_id = str(row["merchant_id"] or "")
122
128
  agent_id = str(row["agent_id"] or "")
123
129
  if not merchant_id or agent_id != token_service.default_merchant_agent_id(merchant_id):
124
130
  raise AuthError("agent token cannot act as a negotiation merchant agent")
125
- return NegotiationActor(role="merchant", owner_id=merchant_id, agent_id=agent_id)
131
+ return NegotiationActor(role="merchant", owner_id=merchant_id, agent_id=agent_id, token_hash=token_hash)
126
132
  raise AuthError("token role cannot negotiate")
127
133
 
128
134
 
@@ -504,7 +510,10 @@ def _merchant_gate(
504
510
  proposal = decision.get("proposal")
505
511
  if decision["action"] in {"propose", "counter"} and proposal is None:
506
512
  return _reject("missing_proposal", "propose/counter 必须携带结构化 proposal。")
507
- product = product_summary(conn, str(conversation["sku"]))
513
+ # 审查 M7:此前直接 product_summary(sku) 取地板价,未校验商品归属本会话商家——
514
+ # SKU 复用/重建时会对错误商家的 automation_boundaries 做 floor/折扣判断。
515
+ # 换用 _load_conversation_product(带归属校验,fail-closed)。
516
+ product = _load_conversation_product(conn, conversation)
508
517
  floor_str = merchant_agent._authorized_bargain_amount(product)
509
518
  # 泄漏守卫对每个 merchant 决策运行——ask/decline 等无 proposal 的决策
510
519
  # 也可能携带泄露底价的 public_message(此前该分支直接放行)。
@@ -697,6 +706,38 @@ def submit_decision(
697
706
  sender = _decision_sender(actor.role)
698
707
  status = _decision_status(actor.role, decision["action"])
699
708
  structured_payload = _decision_structured_payload(actor.agent_id, actor.role, decision, idempotency_key)
709
+ # 幂等 claim:DB 唯一约束兜底并发(审查 H2)。两个并发相同 key 的请求只
710
+ # 有一个能插入成功;另一个在此拿到 IntegrityError,此时对方已提交,回查
711
+ # messages 走重放/冲突。与顶部 _find_decision_replay(服务"丢响应后回合已
712
+ # 推进再重试"的串行幂等)互补,共同覆盖并发 + 串行两条重试路径。
713
+ try:
714
+ conn.execute(
715
+ """
716
+ insert into negotiation_decision_idempotency(
717
+ conversation_id, agent_id, idempotency_key, created_at
718
+ )
719
+ values (?, ?, ?, ?)
720
+ """,
721
+ (conversation_id, actor.agent_id, idempotency_key, now_iso()),
722
+ )
723
+ except sqlite3.IntegrityError:
724
+ replay = _find_decision_replay(conn, actor, conversation_id, idempotency_key)
725
+ if replay is not None:
726
+ if protocol.canonical_json(replay["decision"]) != protocol.canonical_json(decision):
727
+ raise IdempotencyConflict(
728
+ f"idempotency key {idempotency_key} was already used with a different decision"
729
+ )
730
+ refreshed = require_conversation(conn, conversation_id)
731
+ return _policy_result(
732
+ conversation_id,
733
+ "accepted",
734
+ protocol.snapshot_next_actor(str(refreshed["next_actor"] or "")),
735
+ list(decision["reason_codes"]),
736
+ "决策已接受并写入会话(幂等重放,未重复写入)。",
737
+ 0,
738
+ message_id=int(replay["message_id"]),
739
+ )
740
+ raise IdempotencyConflict(f"idempotency key {idempotency_key} was already used")
700
741
  message = append_message(
701
742
  conn,
702
743
  conversation_id,
@@ -706,6 +747,20 @@ def submit_decision(
706
747
  structured_payload=structured_payload,
707
748
  status=status,
708
749
  )
750
+ conn.execute(
751
+ """
752
+ update negotiation_decision_idempotency
753
+ set message_id = ?, decision_json = ?
754
+ where conversation_id = ? and agent_id = ? and idempotency_key = ?
755
+ """,
756
+ (
757
+ int(message["id"]),
758
+ protocol.canonical_json(decision),
759
+ conversation_id,
760
+ actor.agent_id,
761
+ idempotency_key,
762
+ ),
763
+ )
709
764
  if status == "closed":
710
765
  append_conversation_closed_audit(conn, conversation_id, sender, "")
711
766
  append_audit_event(
@@ -112,7 +112,7 @@ def require_api_token(conn: Any, token: Any, missing_error: str = "authorization
112
112
  digest = token_digest(raw_token)
113
113
  row = conn.execute(
114
114
  """
115
- select role, merchant_id, buyer_id, agent_id, conversation_id, revoked_at, expires_at
115
+ select role, merchant_id, buyer_id, agent_id, conversation_id, revoked_at, expires_at, token_hash
116
116
  from api_tokens
117
117
  where token_hash = ?
118
118
  """,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shopping-cli
3
- Version: 3.2.0
3
+ Version: 3.2.1
4
4
  Summary: Standalone local-commerce AI consultation marketplace runtime
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
File without changes
File without changes
File without changes
File without changes