cloudagent-daemon 0.5.0__tar.gz → 0.5.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 (102) hide show
  1. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/CHANGELOG.md +31 -0
  2. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/PKG-INFO +1 -1
  3. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/pyproject.toml +1 -1
  4. cloudagent_daemon-0.5.1/src/cloudagent_daemon/__init__.py +1 -0
  5. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/claude_runner.py +84 -11
  6. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/cli.py +11 -1
  7. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/mcp_bridge.py +46 -2
  8. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/paths.py +3 -0
  9. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/integration/test_mcp_bridge_round_trip.py +66 -0
  10. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_claude_runner.py +65 -12
  11. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_cli_smoke.py +1 -0
  12. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_paths.py +7 -0
  13. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/uv.lock +1 -1
  14. cloudagent_daemon-0.5.0/src/cloudagent_daemon/__init__.py +0 -1
  15. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/.gitignore +0 -0
  16. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/LICENSE +0 -0
  17. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/README.md +0 -0
  18. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/scripts/release.sh +0 -0
  19. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/__main__.py +0 -0
  20. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/agent_download.py +0 -0
  21. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/agent_upload.py +0 -0
  22. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/agent_upload_denylist.py +0 -0
  23. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/auth_preflight.py +0 -0
  24. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/config.py +0 -0
  25. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/context_fetch.py +0 -0
  26. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/delegation_report.py +0 -0
  27. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/doctor.py +0 -0
  28. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/frame_dispatch.py +0 -0
  29. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/http_outbound.py +0 -0
  30. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/injection_state.py +0 -0
  31. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/jsonl_logging.py +0 -0
  32. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/login.py +0 -0
  33. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/main_loop.py +0 -0
  34. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/metrics.py +0 -0
  35. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/metrics_server.py +0 -0
  36. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/outbound_queue.py +0 -0
  37. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/protocol.py +0 -0
  38. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/qoder_credentials.py +0 -0
  39. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/readonly_bash_guard.py +0 -0
  40. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/runtime_config.py +0 -0
  41. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/runtime_detect.py +0 -0
  42. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/service.py +0 -0
  43. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/spawn_outbox.py +0 -0
  44. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/spawn_queue.py +0 -0
  45. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/sse_client.py +0 -0
  46. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/transport.py +0 -0
  47. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/unix_socket.py +0 -0
  48. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/workspace.py +0 -0
  49. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/src/cloudagent_daemon/ws_client.py +0 -0
  50. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/integration/__init__.py +0 -0
  51. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/integration/_fake_sse_server.py +0 -0
  52. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/integration/conftest.py +0 -0
  53. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/integration/test_e2e_deliver_to_spawn.py +0 -0
  54. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/integration/test_e2e_sse.py +0 -0
  55. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/integration/test_full_e2e.py +0 -0
  56. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/integration/test_one_shot_cli.py +0 -0
  57. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/integration/test_spawn_mcp_bridge_e2e.py +0 -0
  58. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/integration/test_sse_handshake.py +0 -0
  59. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/integration/test_sse_watchdog.py +0 -0
  60. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/integration/test_ws_handshake.py +0 -0
  61. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/integration/test_ws_reconnect.py +0 -0
  62. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/conftest.py +0 -0
  63. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_agent_download.py +0 -0
  64. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_agent_upload.py +0 -0
  65. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_auth_preflight.py +0 -0
  66. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_claude_runner_codex_contract.py +0 -0
  67. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_claude_runner_routing_contract.py +0 -0
  68. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_claude_runner_w3.py +0 -0
  69. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_cli_multi_profile.py +0 -0
  70. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_config.py +0 -0
  71. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_context_fetch.py +0 -0
  72. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_delegation_report.py +0 -0
  73. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_doctor.py +0 -0
  74. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_frame_dispatch.py +0 -0
  75. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_http_outbound.py +0 -0
  76. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_injection_state.py +0 -0
  77. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_jsonl_logging.py +0 -0
  78. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_login.py +0 -0
  79. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_main_loop_assign.py +0 -0
  80. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_main_loop_preflight.py +0 -0
  81. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_mcp_bridge_agent_retrieval.py +0 -0
  82. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_mcp_bridge_send_message.py +0 -0
  83. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_metrics.py +0 -0
  84. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_metrics_server.py +0 -0
  85. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_multi_tool_wake.py +0 -0
  86. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_outbound_queue.py +0 -0
  87. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_outbox_stuck_watchdog.py +0 -0
  88. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_packaging_mcp_pin.py +0 -0
  89. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_protocol.py +0 -0
  90. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_qoder_credentials.py +0 -0
  91. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_readonly_bash_guard.py +0 -0
  92. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_runtime_detect.py +0 -0
  93. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_service.py +0 -0
  94. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_service_multi_profile.py +0 -0
  95. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_spawn_outbox.py +0 -0
  96. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_spawn_queue.py +0 -0
  97. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_sse_active_watchdog.py +0 -0
  98. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_start_guard.py +0 -0
  99. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_status_stop.py +0 -0
  100. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_workspace.py +0 -0
  101. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_workspace_root_task113.py +0 -0
  102. {cloudagent_daemon-0.5.0 → cloudagent_daemon-0.5.1}/tests/unit/test_ws_client_insecure.py +0 -0
@@ -4,6 +4,37 @@ All notable changes to the local daemon. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/); versions are
5
5
  [PEP 440](https://peps.python.org/pep-0440/). Release with `scripts/release.sh`.
6
6
 
7
+ ## [0.5.1] — 2026-08-20
8
+
9
+ ### Added
10
+ - Qoder engine now supports cross-owner `read_analyze` (only-read remote
11
+ command). Previously the daemon fail-closed refused `read_analyze` on qoder
12
+ because qodercli's tool-restriction was never verified end-to-end (#106).
13
+ This release ships a proven limited-tool surface:
14
+ - qodercli launched with `--setting-sources '' --permission-mode
15
+ bypass_permissions --tools Read Glob Grep --no-session-persistence
16
+ --strict-mcp-config` and a dedicated read_analyze MCP config; no `--resume`
17
+ of the owner lane.
18
+ - `mcp-bridge --tool-profile read_analyze` applies a POSITIVE allowlist at
19
+ MCP `tools/list` time: only 13 membership-gated query tools remain; every
20
+ write/mutation tool (`platform_send`, `room_post`, `send_message`,
21
+ upload/download, reminder/task mutation, react, login, chief tools) is
22
+ removed. Unknown profile → `ValueError` (fail-closed).
23
+ - Final analysis is delivered by the daemon-controlled fallback to the
24
+ backend-supplied origin conversation only; the model cannot choose a
25
+ destination.
26
+ Verified by real cross-owner E2E (human non-owner → qoder agent, induced
27
+ write/command/`platform_send` all blocked, read-only result returned to the
28
+ origin channel). Confidentiality scope is intentionally the target agent's
29
+ own read ACL (same-org cross-owner read/analysis is the product contract).
30
+
31
+ ### Known debt
32
+ - mcp-bridge per-spawn token-file is still not wired end-to-end; bridge/daemon
33
+ IPC trust rests on the same-user 0600 unix socket, so a malicious native
34
+ process under the same OS user could forge an agent_id. Pre-existing and
35
+ shared by all engines/run-modes; tracked separately (#110), not introduced
36
+ by this release.
37
+
7
38
  ## [0.5.0] — 2026-08-19
8
39
 
9
40
  First release since 0.4.1 (2026-07-22). Everything below shipped to internal
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cloudagent-daemon
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: Local daemon for cloudagent — bridges Claude Code, Codex, and Qoder CLI agents to the cloudagent backend.
5
5
  Project-URL: Homepage, https://cloudagent.chat
6
6
  Project-URL: Repository, https://codeup.aliyun.com/69bd0a4d29ad98af4065ce71/cloudagent
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cloudagent-daemon"
7
- version = "0.5.0"
7
+ version = "0.5.1"
8
8
  description = "Local daemon for cloudagent — bridges Claude Code, Codex, and Qoder CLI agents to the cloudagent backend."
9
9
  authors = [{name = "chaoyin", email = "tudoucyx@gmail.com"}]
10
10
  readme = "README.md"
@@ -0,0 +1 @@
1
+ __version__ = "0.5.1"
@@ -1382,6 +1382,9 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
1382
1382
  wake_prompt = self._codex_stdin_preamble(
1383
1383
  agent_id, append_system_prompt,
1384
1384
  read_analyze=run_mode == "read_analyze",
1385
+ daemon_managed_reply=(
1386
+ runtime_engine == "qoder" and run_mode == "read_analyze"
1387
+ ),
1385
1388
  ) + wake_prompt
1386
1389
  try:
1387
1390
  assert proc.stdin is not None # for type checkers
@@ -1519,6 +1522,19 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
1519
1522
  "mcp__cloudagent__platform_task_status",
1520
1523
  )
1521
1524
 
1525
+ # #107 — qoder read_analyze is enforced with a positive allowlist, not a
1526
+ # denylist. qodercli 1.1.26 live probes showed that ``--tools`` removes
1527
+ # every other built-in capability from the init event even under
1528
+ # ``bypass_permissions``; the latter is still required so the injected
1529
+ # cloudagent MCP server can post the analysis result in headless mode.
1530
+ # Keep this deliberately small: WebFetch/WebSearch are excluded to avoid
1531
+ # an untrusted cross-owner prompt exfiltrating files it just read.
1532
+ QODER_READ_ANALYZE_BUILTIN_TOOLS: tuple[str, ...] = (
1533
+ "Read",
1534
+ "Glob",
1535
+ "Grep",
1536
+ )
1537
+
1522
1538
  # 1i Bug F — operational contract for daemon-spawned wakes.
1523
1539
  # Without this, claude in --print mode just dumps its answer to stdout
1524
1540
  # and exits, never invoking send_message — so the user never sees a reply.
@@ -1875,10 +1891,16 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
1875
1891
  "提交给 owner 审核 —— owner 批准后再由 owner 指挥自己的 agent 实际落盘。"
1876
1892
  "不要试图直接改文件;被拦不是故障,是权限设计。"
1877
1893
  )
1894
+ QODER_READ_ANALYZE_REPLY_NOTE = (
1895
+ "【Qoder 只读回报例外】本模式刻意不向模型暴露 platform_send、"
1896
+ "room_post、send_message 或任何写类 MCP 工具。不要尝试调用这些工具;"
1897
+ "请把完整分析作为本轮最终文本直接输出。daemon 会把该最终文本经受控"
1898
+ "fallback 发回 backend 下发的原始 conversation_id,目标不可由你更改。"
1899
+ )
1878
1900
 
1879
1901
  def _codex_stdin_preamble(
1880
1902
  self, agent_id: str, append_system_prompt: str | None,
1881
- *, read_analyze: bool = False,
1903
+ *, read_analyze: bool = False, daemon_managed_reply: bool = False,
1882
1904
  ) -> str:
1883
1905
  """The system contract as a stdin preamble — codex's substitute for
1884
1906
  ``--append-system-prompt`` (which only the claude argv path has).
@@ -1891,6 +1913,8 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
1891
1913
  parts = [self.SYSTEM_PROMPT_APPEND.format(agent_id=agent_id)]
1892
1914
  if read_analyze:
1893
1915
  parts.append(self.READ_ANALYZE_SYSTEM_NOTE)
1916
+ if daemon_managed_reply:
1917
+ parts.append(self.QODER_READ_ANALYZE_REPLY_NOTE)
1894
1918
  if append_system_prompt and append_system_prompt.strip():
1895
1919
  parts.append(append_system_prompt.strip())
1896
1920
  joined = "\n\n".join(parts)
@@ -1952,6 +1976,38 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
1952
1976
  exc_info=True,
1953
1977
  )
1954
1978
 
1979
+ @staticmethod
1980
+ def _qoder_read_analyze_mcp_config(
1981
+ ws: AgentWorkspaceLike, agent_id: str
1982
+ ) -> Path:
1983
+ """Write the per-wake, query-only cloudagent MCP config for qoder.
1984
+
1985
+ The regular workspace config exposes state-changing tools (send,
1986
+ upload, task mutations, reminders). ``--tools`` only restricts qoder
1987
+ built-ins, so read_analyze needs a separately profiled bridge whose
1988
+ ``tools/list`` contains query-only operations. The daemon fallback,
1989
+ not the model, posts the final analysis back to the source channel.
1990
+ """
1991
+ from .paths import build_mcp_bridge_args, daemon_executable
1992
+
1993
+ config_path = ws.path / ".meta" / "qoder-read-analyze-mcp-config.json"
1994
+ token_file = str(ws.path / ".meta" / "claude-token.txt")
1995
+ config = {
1996
+ "mcpServers": {
1997
+ "cloudagent": {
1998
+ "command": daemon_executable(),
1999
+ "args": build_mcp_bridge_args(
2000
+ agent_id,
2001
+ token_file,
2002
+ tool_profile="read_analyze",
2003
+ ),
2004
+ }
2005
+ }
2006
+ }
2007
+ config_path.parent.mkdir(parents=True, exist_ok=True)
2008
+ config_path.write_text(json.dumps(config, indent=2), encoding="utf-8")
2009
+ return config_path
2010
+
1955
2011
  def _build_cmd(
1956
2012
  self,
1957
2013
  ws: AgentWorkspaceLike,
@@ -2061,20 +2117,14 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2061
2117
  # stream-json/--append-system-prompt/--mcp-config/--resume 全对齐,
2062
2118
  # 输出走既有 stream_json 消费器(联调时如发现事件形状偏差再适配)。
2063
2119
  # 认证:QODER_PERSONAL_ACCESS_TOKEN 环境变量或 qodercli login。
2064
- if read_analyze:
2065
- # fail-closed:qoder 的只读硬化(--tools/--disallowed-tools 的
2066
- # 内建工具名、--permission-mode 无头语义)尚未在真机验证 ——
2067
- # 未验证的安全属性不许上线(#55 口径),宁可拒绝 spawn。
2068
- raise RuntimeError(
2069
- "qoder runtime does not support run_mode=read_analyze yet "
2070
- "(read-only hardening flags unverified); refusing to spawn"
2071
- )
2072
2120
  binary = self.runtime_binaries.get("qoder") or "qodercli"
2073
2121
  # #95 — 会话按 conversation_id 分轨(DM/各频道各自续聊),不再共用
2074
2122
  # 全局 last_session;无 conversation_id 的非平台唤醒沿用全局轨。
2123
+ # #107 — read_analyze 与 claude/codex 一样绝不 resume owner 轨,
2124
+ # 防外部指挥上下文渗进后续 full 会话。
2075
2125
  lane_session = (
2076
2126
  self._qoder_lane_session(ws, conversation_id)
2077
- if with_resume else None
2127
+ if with_resume and not read_analyze else None
2078
2128
  )
2079
2129
  cmd = [binary]
2080
2130
  # r6 同款:模型 + 推理强度由 chairman 在编辑页选(qoder 档位
@@ -2085,13 +2135,18 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2085
2135
  cmd += ["--reasoning-effort", reasoning_effort]
2086
2136
  if lane_session:
2087
2137
  cmd += ["--resume", lane_session]
2138
+ mcp_config_path = (
2139
+ self._qoder_read_analyze_mcp_config(ws, agent_id)
2140
+ if read_analyze
2141
+ else ws.mcp_config_path()
2142
+ )
2088
2143
  # #95 — 运营契约 + 本轮 append(含频道级提示)**不走 argv
2089
2144
  # --append-system-prompt**:qodercli --resume 不可靠采用新的 append,
2090
2145
  # 改与 codex 同款折进 stdin 活跃前导(见 _spawn_attempt),每轮必到
2091
2146
  # 模型眼前,不依赖 CLI 对 resume 的语义。
2092
2147
  cmd += [
2093
2148
  "--mcp-config",
2094
- str(ws.mcp_config_path()),
2149
+ str(mcp_config_path),
2095
2150
  "--print",
2096
2151
  "--output-format",
2097
2152
  "stream-json",
@@ -2100,6 +2155,24 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2100
2155
  "--permission-mode",
2101
2156
  "bypass_permissions",
2102
2157
  ]
2158
+ if read_analyze:
2159
+ # #107 — qodercli 1.1.26 双机真测:--tools 是 built-in
2160
+ # capability allowlist;在 bypass_permissions 下 init.tools
2161
+ # 仍精确为 Glob/Grep/Read,Bash/Write/Edit/NotebookEdit/Web*
2162
+ # 不可调用,诱导写文件的探针保持不存在。同时 bypass 让
2163
+ # cloudagent MCP 能在 --print 无头模式回报结果。
2164
+ #
2165
+ # 空 setting-sources 不加载 user/project/local hooks;strict
2166
+ # MCP 只保留 daemon 生成的 cloudagent 配置;禁用 session
2167
+ # persistence,配合上面的 no-resume 保证 owner 会话隔离。
2168
+ cmd += [
2169
+ "--setting-sources",
2170
+ "",
2171
+ "--no-session-persistence",
2172
+ "--strict-mcp-config",
2173
+ "--tools",
2174
+ *self.QODER_READ_ANALYZE_BUILTIN_TOOLS,
2175
+ ]
2103
2176
  return cmd
2104
2177
 
2105
2178
  cmd = [self.runtime_binaries.get("claude_code") or self.claude_binary]
@@ -973,13 +973,23 @@ def mcp_bridge(
973
973
  ),
974
974
  ),
975
975
  profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
976
+ tool_profile: str = typer.Option(
977
+ "full",
978
+ "--tool-profile",
979
+ help="MCP capability profile (full or read_analyze).",
980
+ ),
976
981
  ) -> None:
977
982
  """[internal] stdio MCP server invoked by claude subprocess."""
978
983
  from .mcp_bridge import run_bridge
979
984
 
980
985
  profile_name = _resolve_cli_profile(profile)
981
986
  asyncio.run(
982
- run_bridge(agent_id=agent_id, token_file=token_file, profile=profile_name)
987
+ run_bridge(
988
+ agent_id=agent_id,
989
+ token_file=token_file,
990
+ profile=profile_name,
991
+ tool_profile=tool_profile,
992
+ )
983
993
  )
984
994
 
985
995
 
@@ -79,6 +79,32 @@ PLATFORM_TOOLS: frozenset[str] = frozenset(
79
79
  }
80
80
  )
81
81
 
82
+ # #107 — cross-owner ``read_analyze`` must expose no state-changing MCP
83
+ # capability to the model. The daemon posts the final analysis through its
84
+ # controlled platform fallback, so ``platform_send`` is intentionally absent;
85
+ # only membership-gated/query-only tools remain visible inside qodercli.
86
+ # Confidentiality scope is intentionally the TARGET AGENT'S existing read ACL,
87
+ # not the initiating human's ACL: same-org cross-owner read/analysis is the
88
+ # product contract. Results can leave that read scope only through the daemon
89
+ # fallback, whose destination is the backend-supplied source conversation.
90
+ READ_ANALYZE_MCP_TOOLS: frozenset[str] = frozenset(
91
+ {
92
+ "list_messages",
93
+ "list_reminders",
94
+ "platform_read",
95
+ "platform_channels_list",
96
+ "platform_search",
97
+ "platform_react_list",
98
+ "platform_inbox",
99
+ "platform_list_integrations",
100
+ "platform_task_list",
101
+ "search_task_context",
102
+ "read_task_room_context",
103
+ "resolve_task_ref",
104
+ "list_related_delegations",
105
+ }
106
+ )
107
+
82
108
 
83
109
  def _read_identity(agent_id: str) -> dict:
84
110
  """Defensive read of ``~/.cloudagent/agents/<id>/.meta/identity.json``.
@@ -206,8 +232,9 @@ def build_server(
206
232
  socket_path: Path = DEFAULT_SOCKET_PATH,
207
233
  identity: dict | None = None,
208
234
  session_id: str | None = None,
235
+ tool_profile: str = "full",
209
236
  ) -> FastMCP:
210
- """Construct a FastMCP server with tools filtered by ``is_chief``.
237
+ """Construct a FastMCP server filtered by identity and tool profile.
211
238
 
212
239
  Split out from ``run_bridge`` so unit tests can introspect the tool
213
240
  list without spinning up stdio.
@@ -226,7 +253,15 @@ def build_server(
226
253
  Pass ``None`` only when no session is known (the backend will then
227
254
  reject with INVALID_ARGS — that's the expected behaviour and
228
255
  surfaces the bug rather than masking it).
256
+
257
+ ``tool_profile='read_analyze'`` is the fail-closed cross-owner surface:
258
+ after normal registration, every tool outside
259
+ :data:`READ_ANALYZE_MCP_TOOLS` is removed. Filtering at MCP registration
260
+ time is stronger than asking the model not to call a write tool: the tool
261
+ never appears in ``tools/list`` and cannot be invoked by name.
229
262
  """
263
+ if tool_profile not in ("full", "read_analyze"):
264
+ raise ValueError(f"unknown MCP tool profile: {tool_profile}")
230
265
  if identity is None:
231
266
  identity = _read_identity(agent_id)
232
267
 
@@ -791,6 +826,11 @@ def build_server(
791
826
  session_id,
792
827
  )
793
828
 
829
+ if tool_profile == "read_analyze":
830
+ for tool in tuple(server._tool_manager.list_tools()):
831
+ if tool.name not in READ_ANALYZE_MCP_TOOLS:
832
+ server.remove_tool(tool.name)
833
+
794
834
  return server
795
835
 
796
836
 
@@ -825,6 +865,7 @@ async def run_bridge(
825
865
  token_file: Path | None = None,
826
866
  socket_path: Path | None = None,
827
867
  profile: str | None = None,
868
+ tool_profile: str = "full",
828
869
  ) -> None:
829
870
  """Run the bridge until claude closes stdin.
830
871
 
@@ -910,10 +951,13 @@ async def run_bridge(
910
951
  socket_path=socket_path,
911
952
  identity=identity,
912
953
  session_id=current_session_id,
954
+ tool_profile=tool_profile,
913
955
  )
914
956
  logger.info(
915
- "mcp_bridge: ready agent=%s is_chief=%s — entering stdio loop",
957
+ "mcp_bridge: ready agent=%s is_chief=%s tool_profile=%s — "
958
+ "entering stdio loop",
916
959
  agent_id,
917
960
  identity.get("is_chief") is True,
961
+ tool_profile,
918
962
  )
919
963
  await server.run_stdio_async()
@@ -200,6 +200,7 @@ def build_mcp_bridge_args(
200
200
  agent_id: str,
201
201
  token_file: str,
202
202
  profile: str | None = None,
203
+ tool_profile: str = "full",
203
204
  ) -> list[str]:
204
205
  """Build argv for the ``cloudagent-daemon mcp-bridge`` MCP server.
205
206
 
@@ -222,6 +223,8 @@ def build_mcp_bridge_args(
222
223
  args = ["mcp-bridge", "--agent-id", agent_id, "--token-file", token_file]
223
224
  if resolved != DEFAULT_PROFILE:
224
225
  args += ["--profile", resolved]
226
+ if tool_profile != "full":
227
+ args += ["--tool-profile", tool_profile]
225
228
  return args
226
229
 
227
230
 
@@ -204,7 +204,10 @@ _COMMON_TOOLS = {
204
204
  "send_message",
205
205
  "list_messages",
206
206
  "upload_file",
207
+ "download_attachment",
207
208
  "schedule_reminder",
209
+ "list_reminders",
210
+ "cancel_reminder",
208
211
  "room_post",
209
212
  }
210
213
  _RETRIEVAL_TOOLS = {
@@ -219,6 +222,8 @@ _RETRIEVAL_TOOLS = {
219
222
  _PLATFORM_TOOLS = {
220
223
  "platform_send",
221
224
  "platform_read",
225
+ "platform_channels_list",
226
+ "platform_search",
222
227
  "platform_react",
223
228
  "platform_react_list",
224
229
  "platform_inbox",
@@ -281,6 +286,67 @@ async def test_missing_is_chief_defaults_to_staff() -> None:
281
286
  assert names.isdisjoint(_CHIEF_ONLY_TOOLS)
282
287
 
283
288
 
289
+ async def test_read_analyze_profile_registers_query_only_tools() -> None:
290
+ """#107 — cross-owner qoder never sees state-changing MCP tools.
291
+
292
+ The final reply is posted by the daemon's controlled fallback, so even
293
+ platform_send is intentionally absent from the model-visible surface.
294
+ """
295
+ server = build_server(
296
+ agent_id="a_read_analyze",
297
+ socket_path=Path("/tmp/never-used.sock"),
298
+ identity={"is_chief": True},
299
+ tool_profile="read_analyze",
300
+ )
301
+ names = {t.name for t in await server.list_tools()}
302
+ assert names == {
303
+ "list_messages",
304
+ "list_reminders",
305
+ "platform_read",
306
+ "platform_channels_list",
307
+ "platform_search",
308
+ "platform_react_list",
309
+ "platform_inbox",
310
+ "platform_list_integrations",
311
+ "platform_task_list",
312
+ "search_task_context",
313
+ "read_task_room_context",
314
+ "resolve_task_ref",
315
+ "list_related_delegations",
316
+ }
317
+ assert names.isdisjoint(
318
+ {
319
+ "send_message",
320
+ "upload_file",
321
+ "download_attachment",
322
+ "schedule_reminder",
323
+ "cancel_reminder",
324
+ "room_post",
325
+ "platform_send",
326
+ "platform_react",
327
+ "platform_login",
328
+ "platform_task_create",
329
+ "platform_task_claim",
330
+ "platform_task_release",
331
+ "platform_task_status",
332
+ "delegate_to_staff",
333
+ "close_task",
334
+ "request_user_approval",
335
+ "set_company_status",
336
+ }
337
+ )
338
+
339
+
340
+ async def test_unknown_tool_profile_fails_closed() -> None:
341
+ with pytest.raises(ValueError, match="unknown MCP tool profile"):
342
+ build_server(
343
+ agent_id="a_bad_profile",
344
+ socket_path=Path("/tmp/never-used.sock"),
345
+ identity={},
346
+ tool_profile="typo",
347
+ )
348
+
349
+
284
350
  async def test_chief_only_tool_handler_actually_callable_when_chief(
285
351
  ipc_server: tuple[Path, list[dict]],
286
352
  ) -> None:
@@ -3036,13 +3036,35 @@ async def test_qoder_runtime_resumes_stored_session(fake_claude, tmp_path):
3036
3036
  assert argv[argv.index("--resume") + 1] == "qoder_old_7"
3037
3037
 
3038
3038
 
3039
- async def test_qoder_runtime_refuses_read_analyze(fake_claude, tmp_path):
3040
- """cr0624 R32 fail-closed — qoder 的只读硬化未经真机验证,read_analyze
3041
- 必须拒绝 spawn 而不是带着未验证的安全属性上线。"""
3039
+ async def test_qoder_runtime_read_analyze_uses_verified_tool_allowlist(
3040
+ fake_claude, tmp_path
3041
+ ):
3042
+ """#107 — verified qoder read_analyze keeps read tools + cloudagent MCP,
3043
+ drops every write/shell/network built-in, and never resumes an owner lane."""
3042
3044
  mcp_cfg = tmp_path / "mcp.json"
3043
3045
  mcp_cfg.write_text("{}")
3044
- ws = FakeWorkspace(FakeAgentWorkspace(mcp_path=mcp_cfg, last_session=None))
3045
- fake = fake_claude(stdout_lines=[], exit_code=0)
3046
+ (tmp_path / ".meta").mkdir()
3047
+ lanes_path = tmp_path / ".meta" / "qoder-sessions.json"
3048
+ lanes_path.write_text(json.dumps({"conv_A": "owner_lane"}))
3049
+ agent_ws = FakeAgentWorkspace(
3050
+ mcp_path=mcp_cfg, last_session="owner_global", path=tmp_path
3051
+ )
3052
+ ws = FakeWorkspace(agent_ws)
3053
+ fake = fake_claude(
3054
+ stdout_lines=[
3055
+ json.dumps({
3056
+ "type": "system",
3057
+ "subtype": "init",
3058
+ "session_id": "readonly_ephemeral",
3059
+ }),
3060
+ json.dumps({
3061
+ "type": "result",
3062
+ "subtype": "success",
3063
+ "result": "analysis",
3064
+ }),
3065
+ ],
3066
+ exit_code=0,
3067
+ )
3046
3068
  runner = ClaudeRunner(
3047
3069
  outbound=FakeOutbound(),
3048
3070
  workspace=ws,
@@ -3050,13 +3072,44 @@ async def test_qoder_runtime_refuses_read_analyze(fake_claude, tmp_path):
3050
3072
  runtime_binaries={"qoder": fake},
3051
3073
  single_wake_timeout=10.0,
3052
3074
  )
3053
- with pytest.raises(RuntimeError, match="read_analyze"):
3054
- runner._build_cmd(
3055
- ws.for_agent("agent-A"),
3056
- "agent-A",
3057
- runtime_engine="qoder",
3058
- run_mode="read_analyze",
3059
- )
3075
+ result = await runner.run_one(
3076
+ "agent-A",
3077
+ {
3078
+ **_make_payload(),
3079
+ "runtime_engine": "qoder",
3080
+ "run_mode": "read_analyze",
3081
+ "conversation_id": "conv_A",
3082
+ },
3083
+ )
3084
+
3085
+ assert result.captured_session_id == "readonly_ephemeral"
3086
+ argv = _read_argv(tmp_path)
3087
+ assert "--resume" not in argv
3088
+ assert argv[argv.index("--permission-mode") + 1] == "bypass_permissions"
3089
+ assert argv[argv.index("--setting-sources") + 1] == ""
3090
+ assert "--no-session-persistence" in argv
3091
+ assert "--strict-mcp-config" in argv
3092
+ assert argv[argv.index("--tools") + 1:] == ["Read", "Glob", "Grep"]
3093
+ for forbidden in (
3094
+ "Bash", "Write", "Edit", "NotebookEdit", "WebFetch", "WebSearch"
3095
+ ):
3096
+ assert forbidden not in argv[argv.index("--tools") + 1:]
3097
+
3098
+ readonly_mcp_path = Path(argv[argv.index("--mcp-config") + 1])
3099
+ assert readonly_mcp_path != mcp_cfg
3100
+ readonly_mcp = json.loads(readonly_mcp_path.read_text())
3101
+ bridge_args = readonly_mcp["mcpServers"]["cloudagent"]["args"]
3102
+ assert bridge_args[-2:] == ["--tool-profile", "read_analyze"]
3103
+
3104
+ stdin = _read_stdin(tmp_path)
3105
+ assert "只读分析模式" in stdin
3106
+ assert "Qoder 只读回报例外" in stdin
3107
+ assert "daemon 会把该最终文本" in stdin
3108
+ # The read-only qoder session is neither resumed nor persisted into the
3109
+ # owner lane/global session track.
3110
+ assert json.loads(lanes_path.read_text()) == {"conv_A": "owner_lane"}
3111
+ assert agent_ws.last_session_id() == "owner_global"
3112
+ assert agent_ws.set_calls == []
3060
3113
 
3061
3114
 
3062
3115
  async def test_qoder_real_event_sequence_converges_on_result(fake_claude, tmp_path):
@@ -72,6 +72,7 @@ def test_mcp_bridge_command_exists_even_though_hidden():
72
72
  assert result.exit_code == 0
73
73
  assert "--agent-id" in result.stdout
74
74
  assert "--token-file" in result.stdout
75
+ assert "--tool-profile" in result.stdout
75
76
 
76
77
 
77
78
  # ---------------------------------------------------------------------------
@@ -131,6 +131,13 @@ def test_build_bridge_args_default_profile_omits_flag() -> None:
131
131
  ]
132
132
 
133
133
 
134
+ def test_build_bridge_args_read_analyze_includes_tool_profile() -> None:
135
+ args = paths.build_mcp_bridge_args(
136
+ "agent_x", "/tok/t.txt", tool_profile="read_analyze"
137
+ )
138
+ assert args[-2:] == ["--tool-profile", "read_analyze"]
139
+
140
+
134
141
  def test_build_bridge_args_none_falls_back_to_env(
135
142
  monkeypatch: pytest.MonkeyPatch,
136
143
  ) -> None:
@@ -122,7 +122,7 @@ wheels = [
122
122
 
123
123
  [[package]]
124
124
  name = "cloudagent-daemon"
125
- version = "0.5.0"
125
+ version = "0.5.1"
126
126
  source = { editable = "." }
127
127
  dependencies = [
128
128
  { name = "h2" },
@@ -1 +0,0 @@
1
- __version__ = "0.5.0"