openclacky 0.9.34 → 0.9.35
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 +4 -4
- data/CHANGELOG.md +30 -0
- data/lib/clacky/agent/cost_tracker.rb +1 -1
- data/lib/clacky/agent/llm_caller.rb +14 -10
- data/lib/clacky/agent/memory_updater.rb +1 -1
- data/lib/clacky/agent/session_serializer.rb +2 -0
- data/lib/clacky/agent/skill_manager.rb +1 -1
- data/lib/clacky/agent/tool_executor.rb +13 -16
- data/lib/clacky/agent/tool_registry.rb +0 -3
- data/lib/clacky/agent.rb +63 -38
- data/lib/clacky/agent_config.rb +5 -1
- data/lib/clacky/brand_config.rb +11 -27
- data/lib/clacky/cli.rb +36 -0
- data/lib/clacky/default_skills/channel-setup/SKILL.md +1 -1
- data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +1 -1
- data/lib/clacky/default_skills/new/SKILL.md +1 -1
- data/lib/clacky/default_skills/product-help/SKILL.md +1 -1
- data/lib/clacky/default_skills/recall-memory/SKILL.md +1 -1
- data/lib/clacky/default_skills/skill-creator/SKILL.md +1 -1
- data/lib/clacky/idle_compression_timer.rb +8 -0
- data/lib/clacky/json_ui_controller.rb +2 -1
- data/lib/clacky/plain_ui_controller.rb +10 -3
- data/lib/clacky/platform_http_client.rb +161 -1
- data/lib/clacky/server/channel/channel_manager.rb +5 -3
- data/lib/clacky/server/channel/channel_ui_controller.rb +6 -2
- data/lib/clacky/server/http_server.rb +235 -40
- data/lib/clacky/server/scheduler.rb +17 -16
- data/lib/clacky/server/session_registry.rb +1 -5
- data/lib/clacky/server/web_ui_controller.rb +7 -6
- data/lib/clacky/session_manager.rb +22 -0
- data/lib/clacky/skill.rb +19 -3
- data/lib/clacky/skill_loader.rb +5 -59
- data/lib/clacky/tools/browser.rb +25 -73
- data/lib/clacky/tools/security.rb +326 -0
- data/lib/clacky/tools/terminal/output_cleaner.rb +63 -0
- data/lib/clacky/tools/terminal/persistent_session.rb +247 -0
- data/lib/clacky/tools/terminal/session_manager.rb +208 -0
- data/lib/clacky/tools/terminal.rb +818 -0
- data/lib/clacky/tools/todo_manager.rb +6 -16
- data/lib/clacky/tools/trash_manager.rb +2 -2
- data/lib/clacky/ui2/components/input_area.rb +11 -2
- data/lib/clacky/ui2/layout_manager.rb +438 -488
- data/lib/clacky/ui2/output_buffer.rb +310 -0
- data/lib/clacky/ui2/ui_controller.rb +72 -21
- data/lib/clacky/ui_interface.rb +1 -1
- data/lib/clacky/utils/encoding.rb +1 -1
- data/lib/clacky/utils/environment_detector.rb +43 -0
- data/lib/clacky/utils/model_pricing.rb +3 -3
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +479 -178
- data/lib/clacky/web/app.js +146 -4
- data/lib/clacky/web/auth.js +101 -0
- data/lib/clacky/web/i18n.js +35 -1
- data/lib/clacky/web/index.html +9 -2
- data/lib/clacky/web/sessions.js +254 -15
- data/lib/clacky/web/skills.js +20 -6
- data/lib/clacky/web/tasks.js +54 -2
- data/lib/clacky/web/theme.js +58 -20
- data/lib/clacky/web/ws.js +11 -2
- data/lib/clacky.rb +2 -2
- metadata +8 -3
- data/lib/clacky/tools/safe_shell.rb +0 -608
- data/lib/clacky/tools/shell.rb +0 -522
data/lib/clacky/skill_loader.rb
CHANGED
|
@@ -11,9 +11,7 @@ module Clacky
|
|
|
11
11
|
# Skill discovery locations (in priority order: lower index = lower priority)
|
|
12
12
|
LOCATIONS = [
|
|
13
13
|
:default, # gem's built-in default skills (lowest priority)
|
|
14
|
-
:global_claude, # ~/.claude/skills/ (compatibility)
|
|
15
14
|
:global_clacky, # ~/.clacky/skills/
|
|
16
|
-
:project_claude, # .claude/skills/ (project-level compatibility)
|
|
17
15
|
:project_clacky, # .clacky/skills/ (highest priority among plain skills)
|
|
18
16
|
:brand # ~/.clacky/brand_skills/ (encrypted, license-gated)
|
|
19
17
|
].freeze
|
|
@@ -25,7 +23,7 @@ module Clacky
|
|
|
25
23
|
|
|
26
24
|
# Initialize the skill loader and automatically load all skills
|
|
27
25
|
# @param working_dir [String, nil] Current working directory for project-level discovery.
|
|
28
|
-
# When nil, project-level skills (.clacky/skills
|
|
26
|
+
# When nil, project-level skills (.clacky/skills/) are not loaded,
|
|
29
27
|
# making the loader project-agnostic (used by WebUI server).
|
|
30
28
|
# @param brand_config [Clacky::BrandConfig, nil] Optional brand config used to
|
|
31
29
|
# decrypt brand skills. When nil, brand skills are silently skipped.
|
|
@@ -52,14 +50,12 @@ module Clacky
|
|
|
52
50
|
clear
|
|
53
51
|
|
|
54
52
|
load_default_skills
|
|
55
|
-
load_global_claude_skills
|
|
56
53
|
load_global_clacky_skills
|
|
57
54
|
|
|
58
55
|
# Only load project-level skills when working_dir is explicitly provided.
|
|
59
56
|
# When nil (e.g. WebUI server mode), skip project skills to keep the loader
|
|
60
57
|
# project-agnostic and only expose global skills.
|
|
61
58
|
if @working_dir
|
|
62
|
-
load_project_claude_skills
|
|
63
59
|
load_project_clacky_skills
|
|
64
60
|
end
|
|
65
61
|
|
|
@@ -101,7 +97,7 @@ module Clacky
|
|
|
101
97
|
|
|
102
98
|
# Skip brand skill when a local plain skill with the same name is already
|
|
103
99
|
# loaded (global_clacky or project_clacky). The local copy shadows it.
|
|
104
|
-
if @skills[skill_name] && %i[global_clacky project_clacky
|
|
100
|
+
if @skills[skill_name] && %i[global_clacky project_clacky].include?(@loaded_from[skill_name])
|
|
105
101
|
@shadowed_by_local ||= {}
|
|
106
102
|
@shadowed_by_local[skill_name] = @loaded_from[skill_name]
|
|
107
103
|
next
|
|
@@ -125,13 +121,6 @@ module Clacky
|
|
|
125
121
|
@shadowed_by_local || {}
|
|
126
122
|
end
|
|
127
123
|
|
|
128
|
-
# Load skills from ~/.claude/skills/ (lowest priority, compatibility)
|
|
129
|
-
# @return [Array<Skill>]
|
|
130
|
-
def load_global_claude_skills
|
|
131
|
-
global_claude_dir = Pathname.new(ENV.fetch("HOME", "~")).join(".claude", "skills")
|
|
132
|
-
load_skills_from_directory(global_claude_dir, :global_claude)
|
|
133
|
-
end
|
|
134
|
-
|
|
135
124
|
# Load skills from ~/.clacky/skills/ (user global)
|
|
136
125
|
# @return [Array<Skill>]
|
|
137
126
|
def load_global_clacky_skills
|
|
@@ -139,13 +128,6 @@ module Clacky
|
|
|
139
128
|
load_skills_from_directory(global_clacky_dir, :global_clacky)
|
|
140
129
|
end
|
|
141
130
|
|
|
142
|
-
# Load skills from .claude/skills/ (project-level compatibility)
|
|
143
|
-
# @return [Array<Skill>]
|
|
144
|
-
def load_project_claude_skills
|
|
145
|
-
project_claude_dir = Pathname.new(@working_dir).join(".claude", "skills")
|
|
146
|
-
load_skills_from_directory(project_claude_dir, :project_claude)
|
|
147
|
-
end
|
|
148
|
-
|
|
149
131
|
# Load skills from .clacky/skills/ (project-level, highest priority)
|
|
150
132
|
# @return [Array<Skill>]
|
|
151
133
|
def load_project_clacky_skills
|
|
@@ -153,39 +135,6 @@ module Clacky
|
|
|
153
135
|
load_skills_from_directory(project_clacky_dir, :project_clacky)
|
|
154
136
|
end
|
|
155
137
|
|
|
156
|
-
# Load skills from nested .claude/skills/ directories (monorepo support)
|
|
157
|
-
# @return [Array<Skill>]
|
|
158
|
-
def load_nested_project_skills
|
|
159
|
-
working_path = Pathname.new(@working_dir)
|
|
160
|
-
|
|
161
|
-
# Find all nested .claude/skills/ directories
|
|
162
|
-
nested_dirs = []
|
|
163
|
-
begin
|
|
164
|
-
Dir.glob("**/.claude/skills/", base: @working_dir).each do |relative_path|
|
|
165
|
-
nested_dirs << working_path.join(relative_path)
|
|
166
|
-
end
|
|
167
|
-
rescue ArgumentError
|
|
168
|
-
# Skip if working_dir contains special characters
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
# Filter out the main project .claude/skills/ (already loaded)
|
|
172
|
-
main_project_skills = working_path.join(".claude", "skills").realpath
|
|
173
|
-
|
|
174
|
-
nested_dirs.each do |dir|
|
|
175
|
-
next if dir.realpath == main_project_skills
|
|
176
|
-
|
|
177
|
-
# Determine the source path for priority resolution
|
|
178
|
-
# Use the parent directory of .claude as the source
|
|
179
|
-
source_path = dir.parent
|
|
180
|
-
|
|
181
|
-
# Determine skill identifier based on relative path from working_dir
|
|
182
|
-
relative_to_working = dir.relative_path_from(working_path).to_s
|
|
183
|
-
skill_name = relative_to_working.gsub(".claude/skills/", "").gsub("/", "-")
|
|
184
|
-
|
|
185
|
-
load_single_skill(dir, source_path, skill_name)
|
|
186
|
-
end
|
|
187
|
-
end
|
|
188
|
-
|
|
189
138
|
# Get all loaded skills
|
|
190
139
|
# @return [Array<Skill>]
|
|
191
140
|
def all_skills
|
|
@@ -340,11 +289,9 @@ module Clacky
|
|
|
340
289
|
skills = []
|
|
341
290
|
dir.children.select(&:directory?).each do |skill_dir|
|
|
342
291
|
source_path = case source_type
|
|
343
|
-
when :global_claude
|
|
344
|
-
Pathname.new(ENV.fetch("HOME", "~")).join(".claude")
|
|
345
292
|
when :global_clacky
|
|
346
293
|
Pathname.new(ENV.fetch("HOME", "~")).join(".clacky")
|
|
347
|
-
when :
|
|
294
|
+
when :project_clacky
|
|
348
295
|
Pathname.new(@working_dir)
|
|
349
296
|
else
|
|
350
297
|
skill_dir
|
|
@@ -402,12 +349,11 @@ module Clacky
|
|
|
402
349
|
# to form a slash command from).
|
|
403
350
|
# - Respects priority ordering for duplicates; enforces MAX_SKILLS cap.
|
|
404
351
|
# @param skill [Skill]
|
|
405
|
-
# @param source [Symbol] one of :default, :
|
|
406
|
-
# :project_claude, :project_clacky, :brand
|
|
352
|
+
# @param source [Symbol] one of :default, :global_clacky, :project_clacky, :brand
|
|
407
353
|
# @return [Skill, nil] nil when the skill was rejected (duplicate/limit)
|
|
408
354
|
private def register_skill(skill, source:)
|
|
409
355
|
id = skill.identifier
|
|
410
|
-
priority_order = %i[default
|
|
356
|
+
priority_order = %i[default global_clacky project_clacky brand]
|
|
411
357
|
|
|
412
358
|
# --- duplicate check ---
|
|
413
359
|
if (existing = @skills[id])
|
data/lib/clacky/tools/browser.rb
CHANGED
|
@@ -29,35 +29,11 @@ module Clacky
|
|
|
29
29
|
# When the selected page has been closed, mcp_call automatically retries once.
|
|
30
30
|
class Browser < Base
|
|
31
31
|
self.tool_name = "browser"
|
|
32
|
-
self.tool_description = <<~DESC
|
|
33
|
-
Control
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
ACTIONS:
|
|
39
|
-
- snapshot → get accessibility tree with element refs. ALWAYS run before interacting.
|
|
40
|
-
- act → interact with page: click, dblclick, type, fill, press, hover, scroll, drag, select, wait, evaluate, click_at
|
|
41
|
-
- open → open URL in a new tab
|
|
42
|
-
- navigate → navigate current tab to URL
|
|
43
|
-
- tabs → list open tabs
|
|
44
|
-
- focus → switch to a tab by target_id
|
|
45
|
-
- close → close a tab by target_id
|
|
46
|
-
- screenshot → EXPENSIVE. Only use when user explicitly asks to "see" the page. Use ref= to capture a single element instead.
|
|
47
|
-
- status → check if browser is running
|
|
48
|
-
|
|
49
|
-
SNAPSHOT WORKFLOW — always snapshot first:
|
|
50
|
-
- action="snapshot", interactive=true → interactive elements only (recommended)
|
|
51
|
-
- action="snapshot", interactive=true, compact=true → compact interactive
|
|
52
|
-
|
|
53
|
-
ACT EXAMPLES:
|
|
54
|
-
- click: ref="e1"
|
|
55
|
-
- click_at: x=100, y=200 → coordinate click, use when ref-based click fails (React/virtual lists)
|
|
56
|
-
- fill: ref="e1", text="value"
|
|
57
|
-
- press: key="Enter"
|
|
58
|
-
- scroll: direction="down", amount=300
|
|
59
|
-
- wait: ms=2000 OR selector=".spinner"
|
|
60
|
-
- evaluate: js="document.title"
|
|
32
|
+
self.tool_description = <<~DESC.strip
|
|
33
|
+
Control user's real Chrome (146+) for web automation. Prefer web_fetch/web_search for read-only pages.
|
|
34
|
+
Actions: snapshot | act | open | navigate | tabs | focus | close | screenshot | status.
|
|
35
|
+
Always snapshot(interactive:true) before act. screenshot is EXPENSIVE — use ref= for a single element.
|
|
36
|
+
act kinds: click, dblclick, type, fill, press, hover, scroll, drag, select, wait, evaluate, click_at (coord fallback).
|
|
61
37
|
DESC
|
|
62
38
|
self.tool_category = "web"
|
|
63
39
|
self.tool_parameters = {
|
|
@@ -65,55 +41,31 @@ module Clacky
|
|
|
65
41
|
properties: {
|
|
66
42
|
action: {
|
|
67
43
|
type: "string",
|
|
68
|
-
enum: %w[snapshot act open navigate tabs focus close screenshot status]
|
|
69
|
-
description: "Action to perform."
|
|
70
|
-
},
|
|
71
|
-
interactive: {
|
|
72
|
-
type: "boolean",
|
|
73
|
-
description: "snapshot: only include interactive elements."
|
|
74
|
-
},
|
|
75
|
-
compact: {
|
|
76
|
-
type: "boolean",
|
|
77
|
-
description: "snapshot: remove empty structural elements."
|
|
78
|
-
},
|
|
79
|
-
depth: {
|
|
80
|
-
type: "integer",
|
|
81
|
-
description: "snapshot: max tree depth."
|
|
82
|
-
},
|
|
83
|
-
selector: {
|
|
84
|
-
type: "string",
|
|
85
|
-
description: "act wait: CSS selector to wait for."
|
|
44
|
+
enum: %w[snapshot act open navigate tabs focus close screenshot status]
|
|
86
45
|
},
|
|
87
46
|
kind: {
|
|
88
47
|
type: "string",
|
|
89
48
|
enum: %w[click dblclick type fill press hover drag select scroll wait evaluate click_at],
|
|
90
|
-
description: "act: interaction kind
|
|
91
|
-
},
|
|
92
|
-
ref: {
|
|
93
|
-
type: "string",
|
|
94
|
-
description: "act: element ref from snapshot (e.g. 'e1'). screenshot: capture only this element (much cheaper)."
|
|
95
|
-
},
|
|
96
|
-
text: { type: "string", description: "act type/fill: text to enter." },
|
|
97
|
-
key: { type: "string", description: "act press: key (e.g. 'Enter')." },
|
|
98
|
-
direction: {
|
|
99
|
-
type: "string",
|
|
100
|
-
enum: %w[up down left right],
|
|
101
|
-
description: "act scroll: direction."
|
|
102
|
-
},
|
|
103
|
-
amount: { type: "integer", description: "act scroll: pixels." },
|
|
104
|
-
ms: { type: "integer", description: "act wait: milliseconds." },
|
|
105
|
-
js: { type: "string", description: "act evaluate: JS expression." },
|
|
106
|
-
target_ref: { type: "string", description: "act drag: destination ref." },
|
|
107
|
-
values: {
|
|
108
|
-
type: "array",
|
|
109
|
-
items: { type: "string" },
|
|
110
|
-
description: "act select: option values."
|
|
49
|
+
description: "act: interaction kind"
|
|
111
50
|
},
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
51
|
+
ref: { type: "string", description: "element ref from snapshot (e.g. 'e1'); screenshot: single element" },
|
|
52
|
+
text: { type: "string", description: "act type/fill text" },
|
|
53
|
+
key: { type: "string", description: "act press key (e.g. 'Enter')" },
|
|
54
|
+
direction: { type: "string", enum: %w[up down left right], description: "act scroll" },
|
|
55
|
+
amount: { type: "integer", description: "act scroll pixels" },
|
|
56
|
+
ms: { type: "integer", description: "act wait ms" },
|
|
57
|
+
selector: { type: "string", description: "act wait CSS selector" },
|
|
58
|
+
js: { type: "string", description: "act evaluate JS" },
|
|
59
|
+
target_ref: { type: "string", description: "act drag destination ref" },
|
|
60
|
+
values: { type: "array", items: { type: "string" }, description: "act select options" },
|
|
61
|
+
x: { type: "number", description: "click_at x px" },
|
|
62
|
+
y: { type: "number", description: "click_at y px" },
|
|
63
|
+
url: { type: "string", description: "open/navigate URL" },
|
|
64
|
+
target_id: { type: "string", description: "focus/close tab id" },
|
|
65
|
+
interactive: { type: "boolean", description: "snapshot: interactive only" },
|
|
66
|
+
compact: { type: "boolean", description: "snapshot: compact" },
|
|
67
|
+
depth: { type: "integer", description: "snapshot: max depth" },
|
|
68
|
+
full_page: { type: "boolean", description: "screenshot: full page" }
|
|
117
69
|
},
|
|
118
70
|
required: ["action"]
|
|
119
71
|
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require_relative "../utils/trash_directory"
|
|
7
|
+
require_relative "../utils/encoding"
|
|
8
|
+
|
|
9
|
+
module Clacky
|
|
10
|
+
module Tools
|
|
11
|
+
# Pre-execution safety layer for shell-style commands.
|
|
12
|
+
#
|
|
13
|
+
# Responsibilities (applied to the `command` string BEFORE it is handed
|
|
14
|
+
# to a shell / PTY for execution):
|
|
15
|
+
#
|
|
16
|
+
# 1. Block hard-dangerous commands: sudo, pkill clacky, eval, exec,
|
|
17
|
+
# `...`, $(...), | sh, | bash,
|
|
18
|
+
# redirect to /etc /usr /bin.
|
|
19
|
+
# 2. Rewrite `rm` → `mv <file> <trash>` so the file is recoverable.
|
|
20
|
+
# 3. Rewrite `curl ... | bash` → save script to a file for manual
|
|
21
|
+
# review instead of exec.
|
|
22
|
+
# 4. Protect important files: Gemfile, Gemfile.lock, .env,
|
|
23
|
+
# package.json, yarn.lock,
|
|
24
|
+
# .ssh/, .aws/, .gitignore,
|
|
25
|
+
# README.md, LICENSE.
|
|
26
|
+
# 5. Confine writes to project_root. `mv`, `cp`, `mkdir` targets
|
|
27
|
+
# outside the project tree are
|
|
28
|
+
# blocked.
|
|
29
|
+
#
|
|
30
|
+
# Raises SecurityError on block. Returns a (possibly rewritten) command
|
|
31
|
+
# string on success.
|
|
32
|
+
#
|
|
33
|
+
# This module was extracted from the former `SafeShell` tool. It is now
|
|
34
|
+
# shared by any tool that executes shell-style commands (currently:
|
|
35
|
+
# `terminal`).
|
|
36
|
+
module Security
|
|
37
|
+
# Raised when a command cannot be made safe.
|
|
38
|
+
class Blocked < StandardError; end
|
|
39
|
+
|
|
40
|
+
# Read-only commands that are considered safe for auto-execution
|
|
41
|
+
# (permission mode :confirm_safes).
|
|
42
|
+
SAFE_READONLY_COMMANDS = %w[
|
|
43
|
+
ls pwd cat less more head tail
|
|
44
|
+
grep find which whereis whoami
|
|
45
|
+
ps top htop df du
|
|
46
|
+
git echo printf wc
|
|
47
|
+
date file stat
|
|
48
|
+
env printenv
|
|
49
|
+
curl wget
|
|
50
|
+
].freeze
|
|
51
|
+
|
|
52
|
+
class << self
|
|
53
|
+
# Process `command` and return a (possibly rewritten) safe version.
|
|
54
|
+
# Raises SecurityError when the command cannot be made safe.
|
|
55
|
+
#
|
|
56
|
+
# @param command [String] command to check
|
|
57
|
+
# @param project_root [String] path treated as the allowed root for writes
|
|
58
|
+
# @return [String] safe command to execute
|
|
59
|
+
def make_safe(command, project_root: Dir.pwd)
|
|
60
|
+
Replacer.new(project_root).make_command_safe(command)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# True iff the command is safe to auto-execute in :confirm_safes mode.
|
|
64
|
+
# (Either a known read-only command, or one that Security.make_safe
|
|
65
|
+
# returns unchanged.)
|
|
66
|
+
def command_safe_for_auto_execution?(command)
|
|
67
|
+
return false unless command
|
|
68
|
+
|
|
69
|
+
cmd_name = command.strip.split.first
|
|
70
|
+
return true if SAFE_READONLY_COMMANDS.include?(cmd_name)
|
|
71
|
+
|
|
72
|
+
begin
|
|
73
|
+
safe = make_safe(command, project_root: Dir.pwd)
|
|
74
|
+
command.strip == safe.strip
|
|
75
|
+
rescue SecurityError
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Internal class that owns per-project state (trash dir, log dir, ...).
|
|
82
|
+
# Extracted almost verbatim from the old SafeShell::CommandSafetyReplacer.
|
|
83
|
+
class Replacer
|
|
84
|
+
def initialize(project_root)
|
|
85
|
+
@project_root = File.expand_path(project_root)
|
|
86
|
+
|
|
87
|
+
trash_directory = Clacky::TrashDirectory.new(@project_root)
|
|
88
|
+
@trash_dir = trash_directory.trash_dir
|
|
89
|
+
@backup_dir = trash_directory.backup_dir
|
|
90
|
+
|
|
91
|
+
@project_hash = trash_directory.generate_project_hash(@project_root)
|
|
92
|
+
@safety_log_dir = File.join(Dir.home, ".clacky", "safety_logs", @project_hash)
|
|
93
|
+
FileUtils.mkdir_p(@safety_log_dir) unless Dir.exist?(@safety_log_dir)
|
|
94
|
+
@safety_log_file = File.join(@safety_log_dir, "safety.log")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def make_command_safe(command)
|
|
98
|
+
command = command.strip
|
|
99
|
+
|
|
100
|
+
# Use a UTF-8-scrubbed copy ONLY for regex checks. The original
|
|
101
|
+
# bytes are returned unchanged so the shell receives exact paths
|
|
102
|
+
# (e.g. GBK-encoded Chinese filenames in zip archives).
|
|
103
|
+
@safe_check_command = Clacky::Utils::Encoding.safe_check(command)
|
|
104
|
+
|
|
105
|
+
case @safe_check_command
|
|
106
|
+
when /pkill.*clacky|killall.*clacky|kill\s+.*\bclacky\b/i
|
|
107
|
+
raise SecurityError, "Killing the clacky server process is not allowed. To restart, use: kill -USR1 $CLACKY_MASTER_PID"
|
|
108
|
+
when /clacky\s+server/
|
|
109
|
+
raise SecurityError, "Managing the clacky server from within a session is not allowed. To restart, use: kill -USR1 $CLACKY_MASTER_PID"
|
|
110
|
+
when /^rm\s+/
|
|
111
|
+
replace_rm_command(command)
|
|
112
|
+
when /^chmod\s+x/
|
|
113
|
+
replace_chmod_command(command)
|
|
114
|
+
when /^curl.*\|\s*(sh|bash)/
|
|
115
|
+
replace_curl_pipe_command(command)
|
|
116
|
+
when /^sudo\s+/
|
|
117
|
+
block_sudo_command(command)
|
|
118
|
+
when />\s*\/dev\/null\s*$/
|
|
119
|
+
allow_dev_null_redirect(command)
|
|
120
|
+
when /^(mv|cp|mkdir|touch|echo)\s+/
|
|
121
|
+
validate_and_allow(command)
|
|
122
|
+
else
|
|
123
|
+
validate_general_command(@safe_check_command)
|
|
124
|
+
command
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def replace_rm_command(command)
|
|
129
|
+
files = parse_rm_files(command)
|
|
130
|
+
raise SecurityError, "No files specified for deletion" if files.empty?
|
|
131
|
+
|
|
132
|
+
commands = files.map do |file|
|
|
133
|
+
validate_file_path(file)
|
|
134
|
+
|
|
135
|
+
timestamp = Time.now.strftime("%Y%m%d_%H%M%S_%N")
|
|
136
|
+
safe_name = "#{File.basename(file)}_deleted_#{timestamp}"
|
|
137
|
+
trash_path = File.join(@trash_dir, safe_name)
|
|
138
|
+
|
|
139
|
+
create_delete_metadata(file, trash_path) if File.exist?(file)
|
|
140
|
+
|
|
141
|
+
"mv #{Shellwords.escape(file)} #{Shellwords.escape(trash_path)}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
result = commands.join(' && ')
|
|
145
|
+
log_replacement("rm", result, "Files moved to trash instead of permanent deletion")
|
|
146
|
+
result
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def replace_chmod_command(command)
|
|
150
|
+
begin
|
|
151
|
+
parts = Shellwords.split(command)
|
|
152
|
+
rescue ArgumentError
|
|
153
|
+
parts = command.split(/\s+/)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
files = parts[2..-1] || []
|
|
157
|
+
files.each { |file| validate_file_path(file) unless file.start_with?('-') }
|
|
158
|
+
|
|
159
|
+
log_replacement("chmod", command, "chmod +x is allowed - file permissions will be modified")
|
|
160
|
+
command
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def replace_curl_pipe_command(command)
|
|
164
|
+
if command.match(/curl\s+(.*?)\s*\|\s*(sh|bash)/)
|
|
165
|
+
url = $1
|
|
166
|
+
shell_type = $2
|
|
167
|
+
timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
|
|
168
|
+
safe_file = File.join(@backup_dir, "downloaded_script_#{timestamp}.sh")
|
|
169
|
+
|
|
170
|
+
result = "curl #{url} -o #{Shellwords.escape(safe_file)} && echo '🔒 Script downloaded to #{safe_file} for manual review. Run: cat #{safe_file}'"
|
|
171
|
+
log_replacement("curl | #{shell_type}", result, "Script saved for manual review instead of automatic execution")
|
|
172
|
+
result
|
|
173
|
+
else
|
|
174
|
+
command
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def block_sudo_command(_command)
|
|
179
|
+
raise SecurityError, "sudo commands are not allowed for security reasons"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def allow_dev_null_redirect(command)
|
|
183
|
+
command
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def validate_and_allow(command)
|
|
187
|
+
begin
|
|
188
|
+
parts = Shellwords.split(command)
|
|
189
|
+
rescue ArgumentError
|
|
190
|
+
parts = command.split(/\s+/)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
cmd = parts.first
|
|
194
|
+
args = parts[1..-1] || []
|
|
195
|
+
|
|
196
|
+
case cmd
|
|
197
|
+
when 'mv', 'cp'
|
|
198
|
+
args.each { |path| validate_file_path(path) unless path.start_with?('-') }
|
|
199
|
+
when 'mkdir'
|
|
200
|
+
args.each { |path| validate_directory_creation(path) unless path.start_with?('-') }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
command
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def validate_general_command(command)
|
|
207
|
+
cmd_without_quotes = command.gsub(/'[^']*'|"[^"]*"/, '')
|
|
208
|
+
|
|
209
|
+
dangerous_patterns = [
|
|
210
|
+
/eval\s*\(/,
|
|
211
|
+
/exec\s*\(/,
|
|
212
|
+
/system\s*\(/,
|
|
213
|
+
/`[^`]+`/,
|
|
214
|
+
/\$\([^)]+\)/,
|
|
215
|
+
/\|\s*sh\s*$/,
|
|
216
|
+
/\|\s*bash\s*$/,
|
|
217
|
+
/>\s*\/etc\//,
|
|
218
|
+
/>\s*\/usr\//,
|
|
219
|
+
/>\s*\/bin\//
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
dangerous_patterns.each do |pattern|
|
|
223
|
+
if cmd_without_quotes.match?(pattern)
|
|
224
|
+
raise SecurityError, "Dangerous command pattern detected: #{pattern.source}"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
command
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def parse_rm_files(command)
|
|
232
|
+
begin
|
|
233
|
+
parts = Shellwords.split(command)
|
|
234
|
+
rescue ArgumentError
|
|
235
|
+
parts = command.split(/\s+/)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
parts.drop(1).reject { |part| part.start_with?('-') }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def validate_file_path(path)
|
|
242
|
+
return if path.start_with?('-')
|
|
243
|
+
|
|
244
|
+
expanded_path = File.expand_path(path)
|
|
245
|
+
|
|
246
|
+
unless expanded_path.start_with?(@project_root)
|
|
247
|
+
raise SecurityError, "File access outside project directory blocked: #{path}"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
protected_patterns = [
|
|
251
|
+
/Gemfile$/,
|
|
252
|
+
/Gemfile\.lock$/,
|
|
253
|
+
/README\.md$/,
|
|
254
|
+
/LICENSE/,
|
|
255
|
+
/\.gitignore$/,
|
|
256
|
+
/package\.json$/,
|
|
257
|
+
/yarn\.lock$/,
|
|
258
|
+
/\.env$/,
|
|
259
|
+
/\.ssh\//,
|
|
260
|
+
/\.aws\//
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
protected_patterns.each do |pattern|
|
|
264
|
+
if expanded_path.match?(pattern)
|
|
265
|
+
raise SecurityError, "Access to protected file blocked: #{File.basename(path)}"
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def validate_directory_creation(path)
|
|
271
|
+
expanded_path = File.expand_path(path)
|
|
272
|
+
|
|
273
|
+
unless expanded_path.start_with?(@project_root)
|
|
274
|
+
raise SecurityError, "Directory creation outside project blocked: #{path}"
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def create_delete_metadata(original_path, trash_path)
|
|
279
|
+
metadata = {
|
|
280
|
+
original_path: File.expand_path(original_path),
|
|
281
|
+
project_root: @project_root,
|
|
282
|
+
trash_directory: File.dirname(trash_path),
|
|
283
|
+
deleted_at: Time.now.iso8601,
|
|
284
|
+
deleted_by: 'AI_Terminal',
|
|
285
|
+
file_size: File.size(original_path),
|
|
286
|
+
file_type: File.extname(original_path),
|
|
287
|
+
file_mode: File.stat(original_path).mode.to_s(8)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
metadata_file = "#{trash_path}.metadata.json"
|
|
291
|
+
File.write(metadata_file, JSON.pretty_generate(metadata))
|
|
292
|
+
rescue StandardError => e
|
|
293
|
+
log_warning("Failed to create metadata for #{original_path}: #{e.message}")
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def log_replacement(original, replacement, reason)
|
|
297
|
+
write_log(
|
|
298
|
+
action: 'command_replacement',
|
|
299
|
+
original_command: original,
|
|
300
|
+
safe_replacement: replacement,
|
|
301
|
+
reason: reason
|
|
302
|
+
)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def log_warning(message)
|
|
306
|
+
write_log(action: 'warning', message: message)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def write_log(**fields)
|
|
310
|
+
log_entry = { timestamp: Time.now.iso8601 }.merge(fields)
|
|
311
|
+
File.open(@safety_log_file, 'a') { |f| f.puts JSON.generate(log_entry) }
|
|
312
|
+
rescue StandardError
|
|
313
|
+
# Logging must never break main functionality.
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
private :replace_rm_command, :replace_chmod_command,
|
|
317
|
+
:replace_curl_pipe_command, :block_sudo_command,
|
|
318
|
+
:allow_dev_null_redirect, :validate_and_allow,
|
|
319
|
+
:validate_general_command, :parse_rm_files,
|
|
320
|
+
:validate_file_path, :validate_directory_creation,
|
|
321
|
+
:create_delete_metadata, :log_replacement,
|
|
322
|
+
:log_warning, :write_log
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
module Tools
|
|
5
|
+
class Terminal < Base
|
|
6
|
+
# Output cleaning for raw PTY bytes.
|
|
7
|
+
#
|
|
8
|
+
# A PTY emits whatever the child writes plus terminal control codes.
|
|
9
|
+
# Since the Terminal tool is targeted at LINE-BASED interactive shells
|
|
10
|
+
# (not full-screen TUIs like vim/top), we aggressively strip visual
|
|
11
|
+
# control sequences rather than maintain a screen model.
|
|
12
|
+
#
|
|
13
|
+
# Cleaning steps (in order):
|
|
14
|
+
# 1. Strip CSI sequences (ESC[...letter) — colors, cursor, SGR
|
|
15
|
+
# 2. Strip OSC sequences (ESC]...BEL/ST) — window title, etc.
|
|
16
|
+
# 3. Strip simple 2-byte esc (ESC= / ESC>) — keypad modes
|
|
17
|
+
# 4. Collapse \r-overwrites (spinner/progress)
|
|
18
|
+
# 5. Drop backspace erase (char + \x08)
|
|
19
|
+
# 6. Normalize CRLF → LF
|
|
20
|
+
#
|
|
21
|
+
# This is lossy for full-screen apps (you'll see a pile of text without
|
|
22
|
+
# cursor positioning), but for line-based commands it yields clean,
|
|
23
|
+
# diff-friendly output.
|
|
24
|
+
module OutputCleaner
|
|
25
|
+
CSI_REGEX = /\e\[[\d;?]*[a-zA-Z@]/.freeze
|
|
26
|
+
OSC_REGEX = /\e\].*?(\a|\e\\)/m.freeze
|
|
27
|
+
SIMPLE_ESC_REGEX = /\e[=>\(\)].?/.freeze
|
|
28
|
+
BACKSPACE_REGEX = /[^\x08]\x08/.freeze
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# Clean raw PTY bytes for LLM consumption.
|
|
33
|
+
# @param raw [String] raw PTY bytes
|
|
34
|
+
# @return [String] cleaned, UTF-8-safe text
|
|
35
|
+
def clean(raw)
|
|
36
|
+
return "" if raw.nil? || raw.empty?
|
|
37
|
+
|
|
38
|
+
s = raw.dup
|
|
39
|
+
s.force_encoding(Encoding::UTF_8)
|
|
40
|
+
s = s.scrub("?") unless s.valid_encoding?
|
|
41
|
+
|
|
42
|
+
s = s.gsub(CSI_REGEX, "")
|
|
43
|
+
s = s.gsub(OSC_REGEX, "")
|
|
44
|
+
s = s.gsub(SIMPLE_ESC_REGEX, "")
|
|
45
|
+
|
|
46
|
+
# Handle \r overwrites within each line. "50%\r100%" → "100%".
|
|
47
|
+
# Split on \n KEEPING the terminators (-1 preserves trailing empty),
|
|
48
|
+
# then for each segment keep only the portion after the last \r
|
|
49
|
+
# (which is what would actually be visible).
|
|
50
|
+
s = s.split("\n", -1).map { |line| line.split("\r").last || "" }.join("\n")
|
|
51
|
+
|
|
52
|
+
# Erase "X\b" pairs repeatedly (readline rubout).
|
|
53
|
+
s = s.gsub(BACKSPACE_REGEX, "") while s =~ BACKSPACE_REGEX
|
|
54
|
+
|
|
55
|
+
# Normalize any leftover isolated \r.
|
|
56
|
+
s = s.gsub(/\r/, "")
|
|
57
|
+
|
|
58
|
+
s
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|