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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/lib/clacky/agent/cost_tracker.rb +1 -1
  4. data/lib/clacky/agent/llm_caller.rb +14 -10
  5. data/lib/clacky/agent/memory_updater.rb +1 -1
  6. data/lib/clacky/agent/session_serializer.rb +2 -0
  7. data/lib/clacky/agent/skill_manager.rb +1 -1
  8. data/lib/clacky/agent/tool_executor.rb +13 -16
  9. data/lib/clacky/agent/tool_registry.rb +0 -3
  10. data/lib/clacky/agent.rb +63 -38
  11. data/lib/clacky/agent_config.rb +5 -1
  12. data/lib/clacky/brand_config.rb +11 -27
  13. data/lib/clacky/cli.rb +36 -0
  14. data/lib/clacky/default_skills/channel-setup/SKILL.md +1 -1
  15. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +1 -1
  16. data/lib/clacky/default_skills/new/SKILL.md +1 -1
  17. data/lib/clacky/default_skills/product-help/SKILL.md +1 -1
  18. data/lib/clacky/default_skills/recall-memory/SKILL.md +1 -1
  19. data/lib/clacky/default_skills/skill-creator/SKILL.md +1 -1
  20. data/lib/clacky/idle_compression_timer.rb +8 -0
  21. data/lib/clacky/json_ui_controller.rb +2 -1
  22. data/lib/clacky/plain_ui_controller.rb +10 -3
  23. data/lib/clacky/platform_http_client.rb +161 -1
  24. data/lib/clacky/server/channel/channel_manager.rb +5 -3
  25. data/lib/clacky/server/channel/channel_ui_controller.rb +6 -2
  26. data/lib/clacky/server/http_server.rb +235 -40
  27. data/lib/clacky/server/scheduler.rb +17 -16
  28. data/lib/clacky/server/session_registry.rb +1 -5
  29. data/lib/clacky/server/web_ui_controller.rb +7 -6
  30. data/lib/clacky/session_manager.rb +22 -0
  31. data/lib/clacky/skill.rb +19 -3
  32. data/lib/clacky/skill_loader.rb +5 -59
  33. data/lib/clacky/tools/browser.rb +25 -73
  34. data/lib/clacky/tools/security.rb +326 -0
  35. data/lib/clacky/tools/terminal/output_cleaner.rb +63 -0
  36. data/lib/clacky/tools/terminal/persistent_session.rb +247 -0
  37. data/lib/clacky/tools/terminal/session_manager.rb +208 -0
  38. data/lib/clacky/tools/terminal.rb +818 -0
  39. data/lib/clacky/tools/todo_manager.rb +6 -16
  40. data/lib/clacky/tools/trash_manager.rb +2 -2
  41. data/lib/clacky/ui2/components/input_area.rb +11 -2
  42. data/lib/clacky/ui2/layout_manager.rb +438 -488
  43. data/lib/clacky/ui2/output_buffer.rb +310 -0
  44. data/lib/clacky/ui2/ui_controller.rb +72 -21
  45. data/lib/clacky/ui_interface.rb +1 -1
  46. data/lib/clacky/utils/encoding.rb +1 -1
  47. data/lib/clacky/utils/environment_detector.rb +43 -0
  48. data/lib/clacky/utils/model_pricing.rb +3 -3
  49. data/lib/clacky/version.rb +1 -1
  50. data/lib/clacky/web/app.css +479 -178
  51. data/lib/clacky/web/app.js +146 -4
  52. data/lib/clacky/web/auth.js +101 -0
  53. data/lib/clacky/web/i18n.js +35 -1
  54. data/lib/clacky/web/index.html +9 -2
  55. data/lib/clacky/web/sessions.js +254 -15
  56. data/lib/clacky/web/skills.js +20 -6
  57. data/lib/clacky/web/tasks.js +54 -2
  58. data/lib/clacky/web/theme.js +58 -20
  59. data/lib/clacky/web/ws.js +11 -2
  60. data/lib/clacky.rb +2 -2
  61. metadata +8 -3
  62. data/lib/clacky/tools/safe_shell.rb +0 -608
  63. data/lib/clacky/tools/shell.rb +0 -522
@@ -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/, .claude/skills/) are not loaded,
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 project_claude global_claude].include?(@loaded_from[skill_name])
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 :project_claude, :project_clacky
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, :global_claude, :global_clacky,
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 global_claude global_clacky project_claude project_clacky brand]
356
+ priority_order = %i[default global_clacky project_clacky brand]
411
357
 
412
358
  # --- duplicate check ---
413
359
  if (existing = @skills[id])
@@ -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 the browser for automation tasks (login, form submission, UI interaction, scraping).
34
- For simple page fetch or search, prefer web_fetch or web_search instead.
35
-
36
- Uses your real Chrome browser with existing logins & cookies. Requires Chrome 146+.
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
- x: { type: "number", description: "act click_at: x coordinate in pixels." },
113
- y: { type: "number", description: "act click_at: y coordinate in pixels." },
114
- url: { type: "string", description: "open/navigate: URL." },
115
- target_id: { type: "string", description: "focus/close: tab id from tabs action." },
116
- full_page: { type: "boolean", description: "screenshot: capture full scrollable page." }
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