cloudagent-daemon 0.3.0__tar.gz → 0.4.0__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 (91) hide show
  1. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/.gitignore +17 -0
  2. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/PKG-INFO +1 -1
  3. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/pyproject.toml +1 -1
  4. cloudagent_daemon-0.4.0/src/cloudagent_daemon/__init__.py +1 -0
  5. cloudagent_daemon-0.4.0/src/cloudagent_daemon/agent_upload.py +201 -0
  6. cloudagent_daemon-0.4.0/src/cloudagent_daemon/agent_upload_denylist.py +84 -0
  7. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/claude_runner.py +1088 -49
  8. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/context_fetch.py +27 -6
  9. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/frame_dispatch.py +24 -7
  10. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/http_outbound.py +36 -0
  11. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/main_loop.py +65 -13
  12. cloudagent_daemon-0.4.0/src/cloudagent_daemon/mcp_bridge.py +851 -0
  13. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/paths.py +59 -0
  14. cloudagent_daemon-0.4.0/src/cloudagent_daemon/readonly_bash_guard.py +140 -0
  15. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/runtime_detect.py +75 -8
  16. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/workspace.py +10 -8
  17. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/integration/test_mcp_bridge_round_trip.py +94 -40
  18. cloudagent_daemon-0.4.0/tests/unit/test_agent_upload.py +185 -0
  19. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_claude_runner.py +913 -3
  20. cloudagent_daemon-0.4.0/tests/unit/test_claude_runner_codex_contract.py +352 -0
  21. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_claude_runner_routing_contract.py +20 -0
  22. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_frame_dispatch.py +44 -9
  23. cloudagent_daemon-0.4.0/tests/unit/test_mcp_bridge_agent_retrieval.py +125 -0
  24. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_mcp_bridge_send_message.py +17 -0
  25. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_paths.py +58 -0
  26. cloudagent_daemon-0.4.0/tests/unit/test_readonly_bash_guard.py +86 -0
  27. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_runtime_detect.py +41 -2
  28. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_workspace.py +5 -1
  29. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/uv.lock +1 -1
  30. cloudagent_daemon-0.3.0/src/cloudagent_daemon/__init__.py +0 -1
  31. cloudagent_daemon-0.3.0/src/cloudagent_daemon/mcp_bridge.py +0 -441
  32. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/LICENSE +0 -0
  33. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/README.md +0 -0
  34. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/__main__.py +0 -0
  35. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/cli.py +0 -0
  36. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/config.py +0 -0
  37. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/delegation_report.py +0 -0
  38. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/doctor.py +0 -0
  39. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/injection_state.py +0 -0
  40. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/jsonl_logging.py +0 -0
  41. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/login.py +0 -0
  42. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/metrics.py +0 -0
  43. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/metrics_server.py +0 -0
  44. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/outbound_queue.py +0 -0
  45. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/protocol.py +0 -0
  46. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/service.py +0 -0
  47. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/spawn_outbox.py +0 -0
  48. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/spawn_queue.py +0 -0
  49. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/sse_client.py +0 -0
  50. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/transport.py +0 -0
  51. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/unix_socket.py +0 -0
  52. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/src/cloudagent_daemon/ws_client.py +0 -0
  53. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/integration/__init__.py +0 -0
  54. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/integration/_fake_sse_server.py +0 -0
  55. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/integration/conftest.py +0 -0
  56. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/integration/test_e2e_deliver_to_spawn.py +0 -0
  57. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/integration/test_e2e_sse.py +0 -0
  58. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/integration/test_full_e2e.py +0 -0
  59. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/integration/test_one_shot_cli.py +0 -0
  60. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/integration/test_spawn_mcp_bridge_e2e.py +0 -0
  61. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/integration/test_sse_handshake.py +0 -0
  62. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/integration/test_sse_watchdog.py +0 -0
  63. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/integration/test_ws_handshake.py +0 -0
  64. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/integration/test_ws_reconnect.py +0 -0
  65. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/conftest.py +0 -0
  66. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_claude_runner_w3.py +0 -0
  67. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_cli_multi_profile.py +0 -0
  68. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_cli_smoke.py +0 -0
  69. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_config.py +0 -0
  70. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_context_fetch.py +0 -0
  71. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_delegation_report.py +0 -0
  72. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_doctor.py +0 -0
  73. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_http_outbound.py +0 -0
  74. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_injection_state.py +0 -0
  75. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_jsonl_logging.py +0 -0
  76. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_login.py +0 -0
  77. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_main_loop_assign.py +0 -0
  78. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_metrics.py +0 -0
  79. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_metrics_server.py +0 -0
  80. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_multi_tool_wake.py +0 -0
  81. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_outbound_queue.py +0 -0
  82. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_outbox_stuck_watchdog.py +0 -0
  83. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_protocol.py +0 -0
  84. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_service.py +0 -0
  85. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_service_multi_profile.py +0 -0
  86. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_spawn_outbox.py +0 -0
  87. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_spawn_queue.py +0 -0
  88. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_sse_active_watchdog.py +0 -0
  89. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_status_stop.py +0 -0
  90. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_workspace_root_task113.py +0 -0
  91. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.4.0}/tests/unit/test_ws_client_insecure.py +0 -0
@@ -49,6 +49,9 @@ CLAUDE.md
49
49
  # Git worktrees (local-only, isolated feature branches)
50
50
  .worktrees/
51
51
 
52
+ # Local code-graph tooling DB (understand-anything / codegraph, ~60MB, not shared)
53
+ .codegraph/
54
+
52
55
  # Local agent scratch output (UI checker screenshots, design data dumps, etc.)
53
56
  .r2c/
54
57
  one-company-fe/deploy
@@ -86,6 +89,20 @@ package-lock.json
86
89
 
87
90
  # playwright 测试相关
88
91
  .playwright-mcp/
92
+ tests/taoguang/
93
+ # fr live sanity 临时 Playwright 脚本 (Alex review 时 drop, 本地用)
94
+ one-company-fe/fr-sanity*.js
89
95
 
90
96
  # M7 设计 demo 的解压产物(源在 Opc.zip,解压即得)
91
97
  company/m7/refactors/Opc_extracted/
98
+
99
+ # understand-anything tool cache
100
+ .understand-anything/
101
+
102
+ # cyber-room-android
103
+ cyber-room-android/.gradle/
104
+ cyber-room-android/build/
105
+ cyber-room-android/app/build/
106
+ cyber-room-android/local.properties
107
+ cyber-room-android/qa/evidence/
108
+ cyber-room-android/qa/.secrets/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudagent-daemon
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Local daemon for cloudagent — bridges Claude Code subprocesses to the cloudagent backend over WebSocket.
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.3.0"
7
+ version = "0.4.0"
8
8
  description = "Local daemon for cloudagent — bridges Claude Code subprocesses to the cloudagent backend over WebSocket."
9
9
  authors = [{name = "chaoyin", email = "tudoucyx@gmail.com"}]
10
10
  readme = "README.md"
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
@@ -0,0 +1,201 @@
1
+ """Local-agent file upload read site (task #9 刀3) — runs in **daemon-main**.
2
+
3
+ The local agent's ``upload_file(path)`` MCP tool (mcp_bridge) only forwards the
4
+ path over IPC — it NEVER reads the file. The actual read happens HERE, in the
5
+ daemon-main process, because only this process has the user's Mac filesystem
6
+ (the backend can't read Mac paths). After the read-site gate passes we hand the
7
+ bytes to the backend's ``/v1/daemon/agent-file-upload`` (刀1) via multipart with
8
+ the resolved realpath + declared size + a cross-check sha.
9
+
10
+ Read-site gate (Alex 刀3): exactly ONE explicit file — no directory / glob /
11
+ recursion; ``os.path.realpath`` (resolves symlinks); the shared home denylist
12
+ (byte-for-byte copy of the backend module); ``stat`` size BEFORE reading bytes;
13
+ bounded read (cap+1) in case the file grew between stat and open; the
14
+ client-computed sha is only a CROSS-CHECK (the backend recomputes the truth).
15
+
16
+ A read-site failure returns ``{"ok": False, ...}`` and NEVER calls the backend
17
+ and NEVER fabricates a success.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import logging
24
+ import mimetypes
25
+ import os
26
+ import stat
27
+ from typing import Any
28
+
29
+ import httpx
30
+
31
+ from .agent_upload_denylist import is_denied
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ _PATH_AGENT_UPLOAD = "/v1/daemon/agent-file-upload"
36
+
37
+
38
+ def _cap() -> int:
39
+ """Per-file byte cap (read-site pre-check). The backend re-enforces its own
40
+ ``settings.agent_upload_max_bytes`` authoritatively; this just rejects early
41
+ and clearly. Overridable via env for parity with the backend default."""
42
+ raw = os.environ.get("CLOUDAGENT_AGENT_UPLOAD_MAX_BYTES", "").strip()
43
+ try:
44
+ v = int(raw)
45
+ if v > 0:
46
+ return v
47
+ except ValueError:
48
+ pass
49
+ return 50 * 1024 * 1024
50
+
51
+
52
+ def _err(code: str, message: str) -> dict[str, Any]:
53
+ return {"ok": False, "error": {"code": code, "message": message}}
54
+
55
+
56
+ def _backend_error_message(resp: httpx.Response) -> str:
57
+ """Pull the agent-readable message out of the backend's ``{"error":{...}}``."""
58
+ try:
59
+ body = resp.json()
60
+ except ValueError:
61
+ return f"upload rejected (backend {resp.status_code})"
62
+ err = body.get("error") if isinstance(body, dict) else None
63
+ if isinstance(err, dict):
64
+ if err.get("message"):
65
+ return str(err["message"])
66
+ if err.get("code"):
67
+ return str(err["code"])
68
+ return f"upload rejected (backend {resp.status_code})"
69
+
70
+
71
+ async def handle_upload_file(
72
+ http: Any,
73
+ *,
74
+ agent_id: str,
75
+ session_id: str | None,
76
+ args: dict[str, Any],
77
+ ) -> dict[str, Any]:
78
+ """Read the LOCAL file (all FS checks here) → multipart to the backend.
79
+
80
+ Returns ``{"ok": True, "data": {...}}`` / ``{"ok": False, "error": {...}}``
81
+ matching the ``send_tool_call`` shape the ipc_handler expects.
82
+ """
83
+ path = args.get("path")
84
+ if not isinstance(path, str) or not path.strip():
85
+ return _err(
86
+ "INVALID_ARGS",
87
+ "upload_file: 'path' must be a non-empty path to ONE file",
88
+ )
89
+ raw = path.strip()
90
+
91
+ # ONE explicit file. Expand ~, realpath (resolves symlinks so a link into a
92
+ # denied dir is caught by its real target).
93
+ rp = os.path.realpath(os.path.expanduser(raw))
94
+ try:
95
+ st = os.stat(rp)
96
+ except OSError as exc:
97
+ return _err(
98
+ "NOT_FOUND",
99
+ f"upload_file: cannot access {raw!r} ({exc.strerror or exc}). "
100
+ "Pass the path to an existing file.",
101
+ )
102
+ if stat.S_ISDIR(st.st_mode):
103
+ return _err(
104
+ "INVALID_ARGS",
105
+ "upload_file: that path is a directory. Pass ONE explicit file "
106
+ "— directories, globs and recursion are not supported.",
107
+ )
108
+ if not stat.S_ISREG(st.st_mode):
109
+ return _err(
110
+ "INVALID_ARGS",
111
+ "upload_file: that path is not a regular file (e.g. a device, "
112
+ "socket or fifo).",
113
+ )
114
+
115
+ # Home-relative + segment denylist (authoritative — symlinks already
116
+ # resolved). Hit → hard failure, no "rename & retry" hint.
117
+ denied, reason = is_denied(rp, home=os.path.expanduser("~"))
118
+ if denied:
119
+ return _err(
120
+ "DENIED",
121
+ f"upload_file: {raw!r} is blocked for security ({reason}). Files "
122
+ "under credential/secret locations (e.g. ~/.ssh, ~/.aws) cannot be "
123
+ "uploaded.",
124
+ )
125
+
126
+ # Size guard BEFORE reading the bytes (stat-based).
127
+ cap = _cap()
128
+ size = st.st_size
129
+ if size == 0:
130
+ return _err("EMPTY", "upload_file: that file is empty.")
131
+ if size > cap:
132
+ return _err(
133
+ "TOO_LARGE",
134
+ f"upload_file: that file is too large ({size} bytes; the limit is "
135
+ f"{cap} bytes).",
136
+ )
137
+
138
+ # Read bytes — size already capped; bounded read in case the file grew
139
+ # between stat and open.
140
+ try:
141
+ with open(rp, "rb") as fh:
142
+ content = fh.read(cap + 1)
143
+ except OSError as exc:
144
+ return _err(
145
+ "READ_FAILED",
146
+ f"upload_file: cannot read {raw!r} ({exc.strerror or exc}).",
147
+ )
148
+ if len(content) == 0:
149
+ return _err("EMPTY", "upload_file: that file is empty.")
150
+ if len(content) > cap:
151
+ return _err(
152
+ "TOO_LARGE",
153
+ f"upload_file: that file is too large (over {cap} bytes).",
154
+ )
155
+
156
+ sha = hashlib.sha256(content).hexdigest()
157
+ fname = os.path.basename(rp)
158
+ mime = mimetypes.guess_type(fname)[0] or "application/octet-stream"
159
+
160
+ # cr0624 #51 — 平台会话(频道/DM)上传:透传 conversation_id,后端改走
161
+ # 平台分支(成员校验 + 自动贴附件消息)。无值 = 老 task 路径,行为不变。
162
+ conv_ref = args.get("conversation_id")
163
+ form: dict[str, str] = {
164
+ "agent_id": agent_id,
165
+ "session_id": session_id or "",
166
+ "client_realpath": rp,
167
+ "declared_size": str(size),
168
+ "content_sha256": sha,
169
+ }
170
+ if isinstance(conv_ref, str) and conv_ref.strip().startswith("conv_"):
171
+ form["conversation_id"] = conv_ref.strip()
172
+
173
+ try:
174
+ resp = await http.post_multipart(
175
+ _PATH_AGENT_UPLOAD,
176
+ files={"file": (fname, content, mime)},
177
+ data=form,
178
+ )
179
+ except httpx.HTTPStatusError as exc:
180
+ return _err("UPLOAD_REJECTED", f"upload_file: {_backend_error_message(exc.response)}")
181
+ except Exception as exc: # noqa: BLE001
182
+ logger.warning("agent_upload: backend call failed: %s", exc, exc_info=True)
183
+ return _err(
184
+ "UPLOAD_FAILED",
185
+ f"upload_file: upload failed ({type(exc).__name__}). Please retry.",
186
+ )
187
+
188
+ name = resp.get("name") or fname
189
+ download_path = resp.get("download_path")
190
+ placed = "频道" if "conversation_id" in form else "协作室"
191
+ return {
192
+ "ok": True,
193
+ "data": {
194
+ "file_id": resp.get("file_id"),
195
+ "filename": name,
196
+ "download_path": download_path,
197
+ "message": (
198
+ f"已上传「{name}」,并已在{placed}贴出附件消息:{download_path}"
199
+ ),
200
+ },
201
+ }
@@ -0,0 +1,84 @@
1
+ """Shared denylist for the agent file-upload feature (任意路径 + 私密目录 denylist).
2
+
3
+ 金山 authorised agent uploads from any explicit file path; Alex's gate (82340720 /
4
+ b41f2cbb) requires a denylist of common credential/secret locations, checked
5
+ **after realpath** so a symlink pointing into a denied directory is caught by its
6
+ real target. A hit is a HARD failure — never hint "rename and retry".
7
+
8
+ The AUTHORITATIVE check runs at the read site (daemon-main for local agents, the
9
+ pod for cloud agents) where the real home dir + filesystem exist: it does
10
+ ``rp = os.path.realpath(path)`` then ``is_denied(rp, home)``. The backend keeps
11
+ this same module (one source of truth) and runs the home-independent
12
+ segment/basename check on the client-supplied realpath as defence-in-depth before
13
+ storing it in the audit record — a buggy or out-of-date client cannot smuggle a
14
+ ``~/.ssh/id_ed25519`` past the server.
15
+
16
+ This module is pure (no FS/network) so both runtimes + the backend share it and it
17
+ is unit-testable in isolation. The daemon package keeps a byte-for-byte copy
18
+ (separate Python package); keep the two in sync.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+
25
+ # Home-relative paths (dirs or files) that must never be uploaded. Checked as an
26
+ # absolute prefix/equal once the caller's home dir is known (the authoritative
27
+ # read-site check). Covers SSH/cloud/k8s/registry creds, GPG, shell rc secrets,
28
+ # the OS keychain, editor secret stores, and the daemon's own config dir.
29
+ DENIED_HOME_RELATIVE: tuple[str, ...] = (
30
+ ".ssh",
31
+ ".aws",
32
+ ".config/gcloud",
33
+ ".config/gh",
34
+ ".kube",
35
+ ".docker",
36
+ ".gnupg",
37
+ ".cloudagent",
38
+ ".npmrc",
39
+ ".pypirc",
40
+ ".netrc",
41
+ ".git-credentials",
42
+ "Library/Keychains",
43
+ "Library/Application Support/Code/User/globalStorage",
44
+ )
45
+
46
+ # Home-independent defence-in-depth: any path *segment* equal to one of these is
47
+ # denied regardless of where home is (catches creds dirs nested anywhere).
48
+ _DENIED_SEGMENTS: frozenset[str] = frozenset(
49
+ {".ssh", ".aws", ".kube", ".docker", ".gnupg", ".cloudagent", "gcloud", "Keychains", "globalStorage"}
50
+ )
51
+
52
+ # Home-independent: any file whose basename matches is denied wherever it lives.
53
+ _DENIED_BASENAMES: frozenset[str] = frozenset(
54
+ {".npmrc", ".pypirc", ".netrc", ".git-credentials"}
55
+ )
56
+
57
+
58
+ def is_denied(realpath: str, home: str | None = None) -> tuple[bool, str | None]:
59
+ """Return ``(True, reason)`` if ``realpath`` is on the denylist, else ``(False, None)``.
60
+
61
+ ``realpath`` MUST already be ``os.path.realpath``-resolved by the caller (so a
62
+ symlink into a denied dir is resolved to its real, denied target before this
63
+ runs). ``home`` enables the authoritative home-relative prefix check at the
64
+ read site; omit it on the backend defence-in-depth pass (segment/basename only).
65
+ """
66
+ norm = os.path.normpath(realpath)
67
+ base = os.path.basename(norm)
68
+
69
+ if base in _DENIED_BASENAMES:
70
+ return True, f"denied file: {base}"
71
+
72
+ segments = norm.split(os.sep)
73
+ for seg in segments:
74
+ if seg in _DENIED_SEGMENTS:
75
+ return True, f"denied path segment: {seg}"
76
+
77
+ if home:
78
+ home_norm = os.path.normpath(home)
79
+ for rel in DENIED_HOME_RELATIVE:
80
+ denied_abs = os.path.normpath(os.path.join(home_norm, rel))
81
+ if norm == denied_abs or norm.startswith(denied_abs + os.sep):
82
+ return True, f"denied path: ~/{rel}"
83
+
84
+ return False, None