rubyn 0.1.5 → 0.1.6

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: 36a16de7cb6802b6a29f1a5ac6f4b6688cd5761c5ac9d3cf90986842885f3e26
4
- data.tar.gz: de2d885c2cd2ba5828817b4242de06b5c05296aa264546e017d3fdf06c9e9bac
3
+ metadata.gz: 0f569a00574395ca84d1262fe5e1b1e43ae3a9f45cbad8bcd635b5f77bc8f964
4
+ data.tar.gz: b649ccb9d369f8d043c7894a2e203aa2de135b5696f0ba3385d58d9cf104595d
5
5
  SHA512:
6
- metadata.gz: 0cdc367ce9ff58c530f3c6fcb5ddacddcc0e7af44763258e62c4226f5531fe5b8d5b8608fa305800be01a2397241c9191e83c31f75114589a3d814da68337c3d
7
- data.tar.gz: 504ae8345f735d6476576f40b16d84cb5959e3732f29c7a145a06f0c5ae7b665abd3a25bd6d13c3513c8eec31d9caad3272510b155b7508ca23be6b530b02019
6
+ metadata.gz: fba7fe70671bbd022cee098572ce9afa1fe5e936983321a7ab04dde76ef088014ac5adad49baa5d9cdb3ea3ee0aa107a36ce5f44c91113dfdc8643fac799c3d6
7
+ data.tar.gz: d64d44658a121a84810c8c659ecf94b71310b6a5f5d88a6954b5301869f2422a2b968001b1fd8612b664b892426fa8b6d769a5eeb15e61610f4724d7ac63f887
@@ -3,6 +3,7 @@
3
3
  require "faraday"
4
4
  require "faraday/multipart"
5
5
  require "json"
6
+ require "securerandom"
6
7
 
7
8
  module Rubyn
8
9
  module Client
@@ -19,7 +20,7 @@ module Rubyn
19
20
  end
20
21
 
21
22
  def refactor(file_path:, code:, context_files:, project_token:)
22
- post("/api/v1/ai/refactor", {
23
+ post_with_recovery("/api/v1/ai/refactor", {
23
24
  file_path: file_path,
24
25
  code: code,
25
26
  context_files: context_files,
@@ -28,7 +29,7 @@ module Rubyn
28
29
  end
29
30
 
30
31
  def generate_spec(file_path:, code:, context_files:, project_token:)
31
- post("/api/v1/ai/spec", {
32
+ post_with_recovery("/api/v1/ai/spec", {
32
33
  file_path: file_path,
33
34
  code: code,
34
35
  context_files: context_files,
@@ -37,7 +38,7 @@ module Rubyn
37
38
  end
38
39
 
39
40
  def review(files:, context_files:, project_token:)
40
- post("/api/v1/ai/review", {
41
+ post_with_recovery("/api/v1/ai/review", {
41
42
  files: files,
42
43
  context_files: context_files,
43
44
  project_token: project_token
@@ -45,7 +46,7 @@ module Rubyn
45
46
  end
46
47
 
47
48
  def agent_message(conversation_id:, message:, file_context:, project_token:)
48
- post("/api/v1/ai/agent", {
49
+ post_with_recovery("/api/v1/ai/agent", {
49
50
  conversation_id: conversation_id,
50
51
  message: message,
51
52
  file_context: file_context,
@@ -99,6 +100,10 @@ module Rubyn
99
100
  get("/api/v1/usage/history", page: page)
100
101
  end
101
102
 
103
+ def fetch_interaction(request_uuid:)
104
+ get("/api/v1/ai/interactions/#{request_uuid}")
105
+ end
106
+
102
107
  def submit_feedback(interaction_id:, rating:, feedback: nil)
103
108
  post("/api/v1/feedback", {
104
109
  interaction_id: interaction_id,
@@ -128,17 +133,69 @@ module Rubyn
128
133
  def get(path, params = {})
129
134
  response = connection.get(path, params)
130
135
  parse_response(response)
136
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
137
+ raise APIError, "Could not connect to #{@base_url} — #{e.message}"
131
138
  end
132
139
 
133
140
  def post(path, body = {})
134
141
  response = connection.post(path, body)
135
142
  parse_response(response)
143
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
144
+ raise APIError, "Could not connect to #{@base_url} — #{e.message}"
145
+ end
146
+
147
+ def post_with_recovery(path, body = {})
148
+ request_uuid = SecureRandom.uuid
149
+ body[:request_uuid] = request_uuid
150
+
151
+ response = connection.post(path, body)
152
+ parse_response(response)
153
+ rescue Faraday::TimeoutError
154
+ recover_response(request_uuid)
155
+ rescue Faraday::ConnectionFailed => e
156
+ raise APIError, "Could not connect to #{@base_url} — #{e.message}"
157
+ end
158
+
159
+ RECOVERY_MESSAGES = [
160
+ "Still polishing those gems...",
161
+ "Matz would want us to be patient...",
162
+ "DHH didn't build Rails in a day...",
163
+ "Optimizing for developer happiness...",
164
+ "Almost there, just one more .each...",
165
+ "Your code is worth the wait..."
166
+ ].freeze
167
+
168
+ def recover_response(request_uuid)
169
+ Rubyn::Output::Formatter.newline
170
+ Rubyn::Output::Formatter.warning("Hang tight! Your response is still cooking...")
171
+
172
+ RECOVERY_MESSAGES.each do |message|
173
+ sleep 5
174
+ begin
175
+ result = fetch_interaction(request_uuid: request_uuid)
176
+ if result && result["response"].to_s.length > 0
177
+ Rubyn::Output::Formatter.success("Got it!")
178
+ Rubyn::Output::Formatter.newline
179
+ return result
180
+ end
181
+ rescue APIError => e
182
+ break if e.message.include?("not found")
183
+ end
184
+ Rubyn::Output::Formatter.info(message)
185
+ end
186
+
187
+ raise APIError, "Request timed out and the response could not be recovered. Your credits were not charged."
136
188
  end
137
189
 
138
190
  def parse_response(response)
139
- return response.body if response.status.between?(200, 299)
191
+ json_body = response.body.is_a?(Hash) || response.body.is_a?(Array)
192
+
193
+ if response.status.between?(200, 299)
194
+ raise APIError, "Unexpected response from server (status #{response.status}). Expected JSON but got #{response.headers["content-type"] || "unknown content type"}." unless json_body
195
+ return response.body
196
+ end
140
197
 
141
- message = extract_error_message(response)
198
+ message = json_body ? extract_error_message(response) : nil
142
199
 
143
200
  case response.status
144
201
  when 400
@@ -114,9 +114,43 @@ module Rubyn
114
114
  "project_token" => project.fetch("project_token"),
115
115
  "project_id" => project.fetch("id")
116
116
  })
117
+ write_default_security_config
117
118
  ensure_gitignore
118
119
  end
119
120
 
121
+ def write_default_security_config
122
+ path = File.join(Dir.pwd, ".rubyn", "security.yml")
123
+ return if File.exist?(path)
124
+
125
+ content = <<~YAML
126
+ # Rubyn Security Configuration
127
+ #
128
+ # blocked_files: Files the agent can NEVER read or write.
129
+ # These are hard-blocked — no confirmation prompt, just denied.
130
+ #
131
+ # sensitive_files: Files the agent can read ONLY with your approval.
132
+ # You'll be prompted before the agent can access these.
133
+ #
134
+ # Patterns support exact paths and directory prefixes:
135
+ # - config/master.key (exact file)
136
+ # - config/credentials (entire directory)
137
+ # - .env.* (glob pattern)
138
+
139
+ blocked_files:
140
+ - config/master.key
141
+ - config/credentials.yml.enc
142
+ - config/credentials
143
+
144
+ sensitive_files:
145
+ - .env
146
+ - .env.*
147
+ - config/database.yml
148
+ - config/secrets.yml
149
+ YAML
150
+
151
+ File.write(path, content)
152
+ end
153
+
120
154
  def ensure_gitignore
121
155
  gitignore_path = File.join(Dir.pwd, ".gitignore")
122
156
  return unless File.exist?(gitignore_path)
@@ -3,9 +3,11 @@
3
3
  module Rubyn
4
4
  module Context
5
5
  class ResponseParser
6
- BOLD_HEADER = /\*\*(New|Updated|Modified)\s*(?:file)?:\s*([a-zA-Z0-9_\/\.\-]+\.rb)\*\*/i
7
- BACKTICK_PATH = /`([a-zA-Z0-9_\/\.\-]+\.rb)`\s*\z/
8
- INLINE_COMMENT = /^#\s*([a-zA-Z0-9_\/\.\-]+\.rb)/
6
+ FILE_EXT = /[a-zA-Z0-9_\/\.\-]+\.[a-zA-Z0-9\.]+/
7
+ BOLD_HEADER = /\*\*(New|Updated|Modified)\s*(?:file)?:\s*(#{FILE_EXT})\*\*/i
8
+ BACKTICK_PATH = /`(#{FILE_EXT})`\s*\z/
9
+ INLINE_COMMENT = /^(?:#|<%#)\s*(#{FILE_EXT})/
10
+ CODE_FENCE = /```\w+\n.*?```/m
9
11
 
10
12
  def self.extract_file_blocks(response)
11
13
  new(response).extract
@@ -17,12 +19,12 @@ module Rubyn
17
19
 
18
20
  def extract
19
21
  blocks = []
20
- parts = @response.split(/(```ruby\n.*?```)/m)
22
+ parts = @response.split(/(#{CODE_FENCE})/m)
21
23
 
22
24
  parts.each_with_index do |part, i|
23
- next unless part.start_with?("```ruby\n")
25
+ next unless part.match?(/\A```\w+\n/)
24
26
 
25
- code = part.sub(/\A```ruby\n/, "").sub(/```\z/, "")
27
+ code = part.sub(/\A```\w+\n/, "").sub(/```\z/, "")
26
28
  preceding = i > 0 ? parts[i - 1] : ""
27
29
  result = detect_path(preceding, code)
28
30
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "yaml"
4
+
3
5
  module Rubyn
4
6
  module Tools
5
7
  class BaseTool
@@ -38,6 +40,18 @@ module Rubyn
38
40
  EXCLUDED_DIRS = %w[.git node_modules vendor/bundle vendor tmp].freeze
39
41
  DEFAULT_MAX_OUTPUT_LENGTH = 10_000
40
42
 
43
+ BLOCKED_FILES = %w[
44
+ config/master.key
45
+ config/credentials.yml.enc
46
+ config/credentials
47
+ ].freeze
48
+
49
+ SENSITIVE_FILES = %w[
50
+ .env
51
+ ].freeze
52
+
53
+ SECURITY_CONFIG_FILE = File.join(".rubyn", "security.yml").freeze
54
+
41
55
  private
42
56
 
43
57
  def excluded?(path)
@@ -55,9 +69,44 @@ module Rubyn
55
69
  expanded = File.expand_path(path, project_root)
56
70
  return error("Access denied: path is outside project root") unless expanded.start_with?(project_root)
57
71
 
72
+ rel = expanded.sub("#{project_root}/", "")
73
+ return error("Access denied: #{rel} is a protected file") if blocked_file?(rel)
74
+
58
75
  expanded
59
76
  end
60
77
 
78
+ def blocked_file?(relative_path)
79
+ all_blocked = BLOCKED_FILES + security_config("blocked_files")
80
+ matches_any?(relative_path, all_blocked)
81
+ end
82
+
83
+ def sensitive_file?(relative_path)
84
+ all_sensitive = SENSITIVE_FILES + security_config("sensitive_files")
85
+ matches_any?(relative_path, all_sensitive)
86
+ end
87
+
88
+ def matches_any?(relative_path, patterns)
89
+ patterns.any? do |pattern|
90
+ relative_path == pattern ||
91
+ relative_path.start_with?("#{pattern}/") ||
92
+ File.fnmatch?(pattern, relative_path, File::FNM_PATHNAME)
93
+ end
94
+ end
95
+
96
+ def security_config(key)
97
+ @security_config ||= load_security_config
98
+ @security_config.fetch(key, [])
99
+ end
100
+
101
+ def load_security_config
102
+ path = File.join(project_root, SECURITY_CONFIG_FILE)
103
+ return {} unless File.exist?(path)
104
+
105
+ YAML.safe_load_file(path) || {}
106
+ rescue Psych::SyntaxError
107
+ {}
108
+ end
109
+
61
110
  def relative_path(full_path)
62
111
  full_path.sub("#{project_root}/", "")
63
112
  end
@@ -28,7 +28,20 @@ module Rubyn
28
28
  return { success: false, error: "denied_by_user" }
29
29
  end
30
30
 
31
- tool.call(params)
31
+ result = tool.call(params)
32
+
33
+ if result.is_a?(Hash) && result[:error].to_s.start_with?("sensitive_file:")
34
+ file = result[:error].sub("sensitive_file:", "")
35
+ Rubyn::Output::Formatter.warning("Agent wants to read sensitive file: #{file}")
36
+ print "Allow? (y/n) "
37
+ if $stdin.gets&.strip&.downcase == "y"
38
+ tool.call(params.merge(skip_sensitive_check: true))
39
+ else
40
+ { success: false, error: "denied_by_user" }
41
+ end
42
+ else
43
+ result
44
+ end
32
45
  rescue StandardError => e
33
46
  { success: false, error: "#{e.class}: #{e.message}" }
34
47
  end
@@ -21,6 +21,7 @@ module Rubyn
21
21
  files = all_files
22
22
  .reject { |f| excluded?(f) }
23
23
  .select { |f| File.file?(f) }
24
+ .reject { |f| blocked_file?(relative_path(f)) }
24
25
  .first(MAX_RESULTS)
25
26
  .map { |f| relative_path(f) }
26
27
 
@@ -28,6 +28,7 @@ module Rubyn
28
28
  rb_files.each do |file|
29
29
  break if references.size >= MAX_RESULTS
30
30
  next if excluded?(file)
31
+ next if blocked_file?(relative_path(file))
31
32
 
32
33
  search_file_for_references(file, regex, references)
33
34
  end
@@ -19,9 +19,13 @@ module Rubyn
19
19
  return resolved if resolved.is_a?(Hash) && resolved[:error]
20
20
 
21
21
  return error("File not found: #{params[:path]}") unless File.exist?(resolved)
22
-
23
22
  return error("Not a file: #{params[:path]}") unless File.file?(resolved)
24
23
 
24
+ rel = relative_path(resolved)
25
+ if sensitive_file?(rel) && !params[:skip_sensitive_check]
26
+ return error("sensitive_file:#{params[:path]}")
27
+ end
28
+
25
29
  lines = File.readlines(resolved)
26
30
  total_lines = lines.length
27
31
 
@@ -39,6 +39,8 @@ module Rubyn
39
39
  break if matches.size >= MAX_MATCHES
40
40
  next unless File.file?(file)
41
41
  next if excluded?(file)
42
+ next if blocked_file?(relative_path(file))
43
+ next if sensitive_file?(relative_path(file))
42
44
  next if binary?(file)
43
45
 
44
46
  search_in_file(file, regex, matches)
data/lib/rubyn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rubyn
4
- VERSION = "0.1.5"
4
+ VERSION = "0.1.6"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubyn
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - matthewsuttles