openclacky 0.9.2 → 0.9.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 634937d9d7a20aa0a76b046ba079ef2d82c398ac81d59014968f7dbeb325726c
4
- data.tar.gz: 7cd5c7eab980c54b5da38b6744dbc21dd3ac48af00c541a609d5537d6867ee70
3
+ metadata.gz: d842684d3cae23106509a9e47be986d51e518f8b953e66011783b32e1a121a6e
4
+ data.tar.gz: 34a8ef45f736724fd7e8356e032a4cd1102bd1a28b47f412419895bd8575caf7
5
5
  SHA512:
6
- metadata.gz: b874781caf58c502536666c33b8aff0c459076689909dd36bd82151f7092953d453d51b2e90259bbfdb6b2eb0ba389e5e56c083a1364118ca0a15edbef471e02
7
- data.tar.gz: 1639031ccf11848ab3b8c2a18d3eb7d87c487dc1e13b856e4c0cdc8cb35e3ebf4c8001660ea607f013e64a40fa1ef09255915652af9d0bc44ad8ddf9c0b1dff2
6
+ metadata.gz: cc9f6ee3d0b8ebf01346261dfd9dbcf6a77654bc40632b5730432fbe5ce23c604f30135739507c1802e837a4baf6097609ba26397c9eea2ec4555b115cd73dc9
7
+ data.tar.gz: 20ad77eb3191fadd1d4340ce7a92b16489be68e83dc4d33fa1d2e802a821074d1964f9a1e7afadcd7cbf9846b81dabebd5cbd918441bb3b3528bfd6212fb5f56
data/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.3] - 2026-03-16
11
+
12
+ ### Added
13
+ - **Brand logo banner on web server startup**: a styled block-font logo now displays in the terminal when `clacky server` launches, giving a polished startup experience
14
+ - **BlockFont renderer replaces artii dependency**: the gem now ships its own high-quality block-font engine for rendering large ASCII logos, removing the external `artii` dependency and enabling full offline use
15
+ - **Hover-to-expand token usage and session info bar**: hovering over the token usage line or session info bar in the WebUI now expands it to show full details, keeping the UI compact by default
16
+ - **Redesigned setup panel with Back button and Custom provider support**: the model setup flow now includes a Back button for navigation and a dedicated "Custom provider" path, making it easier to configure non-standard API endpoints; also fixes a dropdown re-entry bug
17
+ - **License activation via non-blocking top banner**: the brand activation flow no longer blocks the entire UI with a full-screen panel — it now shows a slim top banner, and activation is handled through a dedicated skill
18
+ - **`startSoulSession` exposed on Onboard public interface**: third-party integrations can now trigger soul session initialization directly from the onboard module
19
+
20
+ ### Improved
21
+ - **Browser tool simplified and config-driven**: the browser tool setup is now handled through a unified config object, removing ~250 lines of complex auto-restart logic and making the tool more predictable and maintainable
22
+ - **Prompt caching more stable**: cache anchoring now uses the last assistant message as the stable boundary, reducing cache misses caused by system prompt variations; caching is correctly restored for both Anthropic and OpenRouter paths
23
+ - **Message format extracted to dedicated modules**: OpenAI and Anthropic message formatting now live in separate modules (`Clacky::MessageFormat::OpenAI` and `Clacky::MessageFormat::Anthropic`), making the client code easier to read and test
24
+ - **WeCom channel reliability**: auth failure handling is improved with proper reconnection logic; the `channel-setup` skill guidance is also updated for clarity
25
+ - **Install script and license expiry handling**: the install script is streamlined, license-expired states are handled gracefully, and encrypted skills are decrypted at load time
26
+
27
+ ### Fixed
28
+ - **Prompt cache stability across turns**: cache was occasionally invalidated between turns due to message boundary drift; now anchored reliably to the last assistant message
29
+ - **`request_user_feedback` missing from session history replay**: feedback prompts sent during a session were not rendered when replaying history in the WebUI; they now appear correctly as assistant messages
30
+ - **Brand activation banner not shown when API key is missing**: the banner now correctly appears even when no API key is configured, with a translated skip warning
31
+ - **Zip extraction security**: zip files are now read in chunks with size verification, preventing potential zip-bomb or oversized-file issues
32
+
33
+ ### More
34
+ - Remove browser tool auto-restart logic that was causing instability in headless environments
35
+ - Add security design documentation
36
+
10
37
  ## [0.9.2] - 2026-03-15
11
38
 
12
39
  ### Fixed
@@ -0,0 +1,109 @@
1
+ # openclacky 安全设计方案
2
+
3
+ > 创建时间:2026-03-14
4
+ > 背景:用户反馈 openclacky 可以操作任意文件和电脑,感觉不安全。本文档梳理现有安全机制和待实施的安全策略。
5
+
6
+ ---
7
+
8
+ ## 一、现有安全机制
9
+
10
+ | 机制 | 说明 |
11
+ |---|---|
12
+ | 权限模式 | CLI 默认 `confirm_safes`,危险操作需用户确认 |
13
+ | SafeShell | `rm` → 软删除到 trash、`sudo` 拦截、`curl \| bash` 拦截 |
14
+ | 文件 diff 预览 | `write`/`edit` 操作前展示变更内容 |
15
+ | 安全日志 | 每个项目有 `~/.clacky/safety_logs/<hash>/safety.log` 记录拦截记录 |
16
+ | 受保护文件 | `.env`、`.ssh/`、`.aws/`、`Gemfile` 等不能被删除 |
17
+
18
+ ### 权限模式说明
19
+
20
+ | 模式 | 行为 | 适用场景 |
21
+ |---|---|---|
22
+ | `confirm_safes` | 只读操作自动执行,写/危险 shell 需用户确认 | **CLI 默认** |
23
+ | `confirm_all` | 遇到 `request_user_feedback` 才等待人工 | WebUI session 默认 |
24
+ | `auto_approve` | 全自动执行所有工具,无需确认 | 定时任务默认 |
25
+
26
+ ---
27
+
28
+ ## 二、现有安全薄弱点
29
+
30
+ 1. **`write` 工具无路径限制** — 可写到项目目录外的任意路径(如 `~/.bashrc`)
31
+ 2. **`shell` 和 `safe_shell` 同时注册** — AI 可能绕过 safe_shell 直接用 `shell`
32
+ 3. **WebUI 无认证** — 任何能访问本地端口的进程或局域网用户都能控制 Agent
33
+ 4. **write 覆盖无备份** — 文件被 AI 改写后无法恢复(rm 有软删除,write 没有)
34
+ 5. **安全机制不可见** — 用户感知不到现有保护,导致不信任
35
+
36
+ ---
37
+
38
+ ## 三、安全策略(待实施)
39
+
40
+ ### 🟢 第一梯队:低成本、高收益(推荐优先)
41
+
42
+ #### 1. 操作审计日志
43
+ - Agent 执行的每个 `write`/`edit`/`shell` 操作,写入 `~/.clacky/audit.log`
44
+ - 格式:时间戳 + 项目 + 操作类型 + 详情
45
+ - 提供 `clacky audit` 命令查看历史操作
46
+ - **价值**:让用户看得见 AI 做了什么,信任感最强
47
+
48
+ #### 2. 权限模式状态可见化
49
+ - WebUI 顶部常驻显示当前权限级别,颜色区分
50
+ - 🔴 `auto_approve` — 全自动,无需确认
51
+ - 🟡 `confirm_safes` — 危险操作需确认(推荐)
52
+ - 🟢 `confirm_all` — 所有操作均需确认
53
+ - CLI 启动时打印当前权限模式
54
+
55
+ #### 3. 统一走 safe_shell(堵漏洞)
56
+ - 移除裸 `shell` 工具,只保留 `safe_shell`
57
+ - 确保所有 shell 命令都经过安全替换器处理
58
+ - 成本极低,安全提升明显
59
+
60
+ ---
61
+
62
+ ### 🟡 第二梯队:中等成本、用户感知强
63
+
64
+ #### 4. write/edit 路径白名单
65
+ - 只允许写项目工作目录内的文件
66
+ - 写项目外路径(如 `~/.bashrc`、`~/.ssh/`)需额外确认,或默认拒绝
67
+ - 防止 AI 越界修改系统/用户配置文件
68
+
69
+ #### 5. WebUI 本地认证 Token
70
+ - 启动 Web 服务时生成随机 token,终端显示带 token 的访问链接
71
+ - 防止本地其他进程或局域网内的人劫持 Agent
72
+ - 参考:Jupyter Notebook 的成熟方案
73
+
74
+ #### 6. write 操作自动备份(撤销支持)
75
+ - `write` 覆盖文件前,自动备份原文件到 `~/.clacky/trash/`
76
+ - 支持 `clacky undo` 恢复被 AI 改写的文件
77
+ - 用户最担心的就是"AI 把我文件改了找不回来"
78
+
79
+ ---
80
+
81
+ ### 🔵 第三梯队:大投入、长期规划
82
+
83
+ #### 7. 沙箱模式(Docker)
84
+ - 可选的隔离执行环境,把 Agent 限制在容器内
85
+ - 主机文件系统与 Agent 完全隔离
86
+ - 适合高敏感场景,启动成本高、对体验有影响
87
+
88
+ #### 8. 网络访问白名单
89
+ - 控制 `web_fetch`/`curl` 能访问的域名范围
90
+ - 适合企业级部署场景
91
+
92
+ ---
93
+
94
+ ## 四、实施优先级建议
95
+
96
+ 1. **审计日志** — 让用户"看得见"AI 做了什么,信任感提升最显著
97
+ 2. **write/edit 路径白名单** — 堵最大的安全漏洞
98
+ 3. **统一走 safe_shell** — 低成本堵漏,可顺手实施
99
+
100
+ ---
101
+
102
+ ## 五、让用户放心的核心原则
103
+
104
+ > **安全机制不够,用户感知更重要。**
105
+
106
+ - 用户能看到 AI 正在做什么(实时显示)
107
+ - 用户能看到 AI 做过什么(审计日志)
108
+ - 用户能撤销 AI 的操作(备份+恢复)
109
+ - 用户能控制 AI 的权限(权限模式可见+可切换)
@@ -153,113 +153,126 @@ module Clacky
153
153
  )
154
154
  end
155
155
 
156
- # Get recent messages while preserving tool_calls/tool_results pairs
157
- # This ensures assistant messages with tool_calls are kept together with ALL their tool results
156
+ # Get recent messages while preserving tool_calls/tool_results pairs.
157
+ # Handles both canonical format (role: "tool") and legacy Anthropic-native
158
+ # format (role: "user" with tool_result content blocks).
158
159
  # @param messages [Array] All messages
159
160
  # @param count [Integer] Target number of recent messages to keep
160
161
  # @return [Array] Recent messages with complete tool pairs
161
162
  def get_recent_messages_with_tool_pairs(messages, count)
162
- # This method ensures that assistant messages with tool_calls are always kept together
163
- # with ALL their corresponding tool_results, maintaining the correct order.
164
- # This is critical for Bedrock Claude API which validates the tool_calls/tool_results pairing.
165
-
166
163
  return [] if messages.nil? || messages.empty?
167
164
 
168
- # Track which messages to include
169
165
  messages_to_include = Set.new
170
-
171
- # Start from the end and work backwards
172
166
  i = messages.size - 1
173
167
  messages_collected = 0
174
168
 
175
169
  while i >= 0 && messages_collected < count
176
170
  msg = messages[i]
177
171
 
178
- # Skip if already marked for inclusion
179
172
  if messages_to_include.include?(i)
180
173
  i -= 1
181
174
  next
182
175
  end
183
176
 
184
- # Mark this message for inclusion
185
177
  messages_to_include.add(i)
186
178
  messages_collected += 1
187
179
 
188
- # If this is an assistant message with tool_calls, we MUST include ALL corresponding tool results
189
- if msg[:role] == "assistant" && msg[:tool_calls]
190
- tool_call_ids = msg[:tool_calls].map { |tc| tc[:id] }
191
-
192
- # Find all tool results that belong to this assistant message
193
- # They should be in the messages immediately following this assistant message
194
- j = i + 1
195
- while j < messages.size
196
- next_msg = messages[j]
197
-
198
- # If we find a tool result for one of our tool_calls, include it
199
- if next_msg[:role] == "tool" && tool_call_ids.include?(next_msg[:tool_call_id])
200
- messages_to_include.add(j)
201
- elsif next_msg[:role] != "tool"
202
- # Stop when we hit a non-tool message (start of next turn)
203
- break
204
- end
205
-
206
- j += 1
207
- end
180
+ # assistant with tool_calls also pull in all following tool results
181
+ if msg[:role] == "assistant" && msg[:tool_calls]&.any?
182
+ pull_tool_results_after(messages, i, messages_to_include)
208
183
  end
209
184
 
210
- # If this is a tool result, make sure its assistant message is also included
211
- if msg[:role] == "tool"
212
- # Find the corresponding assistant message
213
- j = i - 1
214
- while j >= 0
215
- prev_msg = messages[j]
216
- if prev_msg[:role] == "assistant" && prev_msg[:tool_calls]
217
- # Check if this assistant has the matching tool_call
218
- has_matching_call = prev_msg[:tool_calls].any? { |tc| tc[:id] == msg[:tool_call_id] }
219
- if has_matching_call
220
- unless messages_to_include.include?(j)
221
- messages_to_include.add(j)
222
- messages_collected += 1
223
- end
224
-
225
- # Also include all other tool results for this assistant message
226
- tool_call_ids = prev_msg[:tool_calls].map { |tc| tc[:id] }
227
- k = j + 1
228
- while k < messages.size
229
- result_msg = messages[k]
230
- if result_msg[:role] == "tool" && tool_call_ids.include?(result_msg[:tool_call_id])
231
- messages_to_include.add(k)
232
- elsif result_msg[:role] != "tool"
233
- break
234
- end
235
- k += 1
236
- end
237
-
238
- break
239
- end
240
- end
241
- j -= 1
185
+ # tool result (canonical or legacy Anthropic) also pull in its assistant
186
+ if tool_result_message?(msg)
187
+ pull_assistant_before(messages, i, messages_to_include) do |added|
188
+ messages_collected += 1 if added
242
189
  end
243
190
  end
244
191
 
245
192
  i -= 1
246
193
  end
247
194
 
248
- # Extract the messages in their original order
249
195
  recent_messages = messages_to_include.to_a.sort.map { |idx| messages[idx] }
250
196
 
251
197
  # Truncate large tool results to prevent token bloat
252
198
  recent_messages.map do |msg|
253
- if msg[:role] == "tool" && msg[:content].is_a?(String) && msg[:content].length > 2000
254
- msg.merge(content: msg[:content][0..2000] + "...\n[Content truncated - exceeded 2000 characters]")
255
- else
256
- msg
257
- end
199
+ truncate_tool_result(msg)
258
200
  end
259
201
  end
260
202
 
261
203
  private
262
204
 
205
+ # Returns true if msg is a tool result, regardless of storage format.
206
+ # Canonical: role:"tool" | Legacy Anthropic-native: role:"user" + tool_result blocks
207
+ def tool_result_message?(msg)
208
+ MessageFormat::OpenAI.tool_result_message?(msg) ||
209
+ MessageFormat::Anthropic.tool_result_message?(msg)
210
+ end
211
+
212
+ # Returns the tool_call IDs referenced in a tool result message.
213
+ def tool_result_ids(msg)
214
+ if MessageFormat::OpenAI.tool_result_message?(msg)
215
+ MessageFormat::OpenAI.tool_call_ids(msg)
216
+ else
217
+ MessageFormat::Anthropic.tool_use_ids(msg)
218
+ end
219
+ end
220
+
221
+ # Returns true if msg is a tool result that matches any of the given call IDs.
222
+ def tool_result_for?(msg, call_ids)
223
+ tool_result_message?(msg) && (tool_result_ids(msg) & call_ids).any?
224
+ end
225
+
226
+ # Mark all tool results immediately following messages[assistant_idx].
227
+ # Stops at the first non-tool-result message.
228
+ def pull_tool_results_after(messages, assistant_idx, include_set)
229
+ call_ids = messages[assistant_idx][:tool_calls].map { |tc| tc[:id] }
230
+ j = assistant_idx + 1
231
+ while j < messages.size
232
+ nxt = messages[j]
233
+ if tool_result_for?(nxt, call_ids)
234
+ include_set.add(j)
235
+ elsif !tool_result_message?(nxt)
236
+ break
237
+ end
238
+ j += 1
239
+ end
240
+ end
241
+
242
+ # Walk backwards from tool_result_idx to find and mark its assistant message.
243
+ # Also marks all sibling tool results for that assistant.
244
+ # Yields true if the assistant was newly added (for caller to increment count).
245
+ def pull_assistant_before(messages, tool_result_idx, include_set)
246
+ result_ids = tool_result_ids(messages[tool_result_idx])
247
+
248
+ j = tool_result_idx - 1
249
+ while j >= 0
250
+ prev = messages[j]
251
+ if prev[:role] == "assistant" && prev[:tool_calls]&.any?
252
+ call_ids = prev[:tool_calls].map { |tc| tc[:id] }
253
+ if (call_ids & result_ids).any?
254
+ newly_added = include_set.add?(j)
255
+ yield newly_added
256
+
257
+ # Also pull all sibling tool results for this assistant
258
+ pull_tool_results_after(messages, j, include_set)
259
+ break
260
+ end
261
+ end
262
+ j -= 1
263
+ end
264
+ end
265
+
266
+ # Truncate oversized tool result content to avoid token bloat.
267
+ def truncate_tool_result(msg)
268
+ if MessageFormat::OpenAI.tool_result_message?(msg) &&
269
+ msg[:content].is_a?(String) && msg[:content].length > 2000
270
+ msg.merge(content: msg[:content][0..2000] + "...\n[Content truncated - exceeded 2000 characters]")
271
+ else
272
+ msg
273
+ end
274
+ end
275
+
263
276
  # Save the messages being compressed to a chunk MD file for future recall
264
277
  # File path: ~/.clacky/sessions/{datetime}-{short_id}-chunk-{n}.md
265
278
  # @param original_messages [Array<Hash>] All messages before compression (excluding compression instruction)
@@ -191,7 +191,15 @@ module Clacky
191
191
  name = tc[:name] || tc.dig(:function, :name) || ""
192
192
  args_raw = tc[:arguments] || tc.dig(:function, :arguments) || {}
193
193
  args = args_raw.is_a?(String) ? (JSON.parse(args_raw) rescue args_raw) : args_raw
194
- ui.show_tool_call(name, args)
194
+
195
+ # Special handling: request_user_feedback question is shown as an
196
+ # assistant message (matching real-time behavior), not as a tool call.
197
+ if name == "request_user_feedback"
198
+ question = args.is_a?(Hash) ? (args[:question] || args["question"]).to_s : ""
199
+ ui.show_assistant_message(question) unless question.empty?
200
+ else
201
+ ui.show_tool_call(name, args)
202
+ end
195
203
  end
196
204
 
197
205
  # Emit token usage stored on this message (for history replay display)
@@ -155,6 +155,13 @@ module Clacky
155
155
  skill = parsed[:skill]
156
156
  arguments = parsed[:arguments]
157
157
 
158
+ # Encrypted brand skills and fork-agent skills must run in an isolated subagent.
159
+ # Injecting their plaintext into @messages would expose confidential content to the LLM.
160
+ if skill.encrypted? || skill.fork_agent?
161
+ execute_skill_with_subagent(skill, arguments)
162
+ return
163
+ end
164
+
158
165
  # Expand skill content (substitutes $ARGUMENTS if present)
159
166
  expanded_content = skill.process_content(arguments, template_context: build_template_context)
160
167
 
data/lib/clacky/agent.rb CHANGED
@@ -671,16 +671,24 @@ module Clacky
671
671
  private def format_tool_calls_for_api(tool_calls)
672
672
  return nil unless tool_calls
673
673
 
674
- tool_calls.map do |call|
674
+ valid = tool_calls.filter_map do |call|
675
+ func = call[:function] || call
676
+ name = func[:name] || call[:name]
677
+ arguments = func[:arguments] || call[:arguments]
678
+ # Skip malformed tool calls with nil name or arguments
679
+ next if name.nil? || arguments.nil?
680
+
675
681
  {
676
682
  id: call[:id],
677
683
  type: call[:type] || "function",
678
684
  function: {
679
- name: call[:name],
680
- arguments: call[:arguments]
685
+ name: name,
686
+ arguments: arguments
681
687
  }
682
688
  }
683
689
  end
690
+
691
+ valid.any? ? valid : nil
684
692
  end
685
693
 
686
694
  private def register_builtin_tools
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require_relative "version"
5
+ require_relative "brand_config"
6
+ require_relative "block_font"
7
+
8
+ module Clacky
9
+ # Banner provides logo and branding for CLI and Web UI startup.
10
+ # Lightweight — no terminal UI dependencies.
11
+ class Banner
12
+ DEFAULT_CLI_LOGO = <<~'LOGO'
13
+ ██████╗ ██████╗ ███████╗███╗ ██╗ ██████╗██╗ █████╗ ██████╗██╗ ██╗██╗ ██╗
14
+ ██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔════╝██║ ██╔══██╗██╔════╝██║ ██╔╝╚██╗ ██╔╝
15
+ ██║ ██║██████╔╝█████╗ ██╔██╗ ██║██║ ██║ ███████║██║ █████╔╝ ╚████╔╝
16
+ ██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██║ ██║ ██╔══██║██║ ██╔═██╗ ╚██╔╝
17
+ ╚██████╔╝██║ ███████╗██║ ╚████║╚██████╗███████╗██║ ██║╚██████╗██║ ██╗ ██║
18
+ ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═╝
19
+ LOGO
20
+
21
+ TAGLINE = "[>] Your personal Assistant & Technical Co-founder"
22
+
23
+ def initialize
24
+ @pastel = Pastel.new
25
+ @brand = BrandConfig.load
26
+ end
27
+
28
+ # Returns the CLI logo text.
29
+ # If branded, renders brand_command using BlockFont (big Unicode art).
30
+ # Falls back to default OPENCLACKY logo when not branded.
31
+ def cli_logo
32
+ if @brand.branded?
33
+ render_key = @brand.brand_command.to_s.strip
34
+ render_key = "clacky" if render_key.empty?
35
+ Clacky::BlockFont.render(render_key)
36
+ else
37
+ DEFAULT_CLI_LOGO
38
+ end
39
+ end
40
+
41
+ # Returns the tagline string.
42
+ def tagline
43
+ if @brand.branded?
44
+ @brand.brand_name.to_s
45
+ else
46
+ TAGLINE
47
+ end
48
+ end
49
+
50
+ # Renders the CLI logo as colored text
51
+ def colored_cli_logo
52
+ @pastel.bright_green(cli_logo)
53
+ end
54
+
55
+ # Renders the tagline as colored text
56
+ def colored_tagline
57
+ @pastel.bright_cyan(tagline)
58
+ end
59
+
60
+ # Renders a URL with bold + underline for emphasis
61
+ def highlight(url)
62
+ @pastel.bold.underline(url)
63
+ end
64
+ end
65
+ end