aidp 0.41.0 → 0.43.0

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.
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "time"
5
+ require_relative "../config_paths"
6
+
7
+ module Aidp
8
+ module Security
9
+ # Deterministic MCP tool risk profile generated once during configuration.
10
+ class McpRiskProfile
11
+ VERSION = 1
12
+ VALID_FLAGS = %w[untrusted_input private_data egress].freeze
13
+ VALID_RISK_LEVELS = %w[low medium high].freeze
14
+
15
+ attr_reader :generated_at, :generator_model, :version, :tools
16
+
17
+ def self.load(project_dir = Dir.pwd)
18
+ path = Aidp::ConfigPaths.mcp_risk_profile_file(project_dir)
19
+ return new(tools: {}) unless File.exist?(path)
20
+
21
+ data = YAML.safe_load_file(path, permitted_classes: [Symbol], symbolize_names: true) || {}
22
+ new(
23
+ generated_at: data[:generated_at],
24
+ generator_model: data[:generator_model],
25
+ version: data[:version] || VERSION,
26
+ tools: data[:tools] || {}
27
+ )
28
+ rescue Psych::Exception => e
29
+ Aidp.log_error("security.mcp_risk_profile", "load_failed", path: path, error: e.message)
30
+ new(tools: {})
31
+ end
32
+
33
+ def initialize(generated_at: nil, generator_model: nil, version: VERSION, tools: {})
34
+ @generated_at = generated_at
35
+ @generator_model = generator_model
36
+ @version = version
37
+ @tools = normalize_tools(tools)
38
+ end
39
+
40
+ def save!(project_dir = Dir.pwd)
41
+ Aidp::ConfigPaths.ensure_security_dir(project_dir)
42
+ File.write(Aidp::ConfigPaths.mcp_risk_profile_file(project_dir), YAML.dump(to_h))
43
+ end
44
+
45
+ def tool(tool_name)
46
+ tools[tool_name.to_s]
47
+ end
48
+
49
+ def flags_for(tool_name)
50
+ tool(tool_name)&.fetch(:flags, []) || []
51
+ end
52
+
53
+ def risk_level_for(tool_name)
54
+ tool(tool_name)&.fetch(:risk_level, nil)
55
+ end
56
+
57
+ def empty?
58
+ tools.empty?
59
+ end
60
+
61
+ def to_h
62
+ {
63
+ "generated_at" => generated_at,
64
+ "generator_model" => generator_model,
65
+ "version" => version,
66
+ "tools" => tools.transform_values do |tool_data|
67
+ {
68
+ "flags" => tool_data[:flags],
69
+ "risk_level" => tool_data[:risk_level],
70
+ "rationale" => tool_data[:rationale]
71
+ }
72
+ end
73
+ }
74
+ end
75
+
76
+ private
77
+
78
+ def normalize_tools(raw_tools)
79
+ raw_tools.each_with_object({}) do |(name, tool_data), normalized|
80
+ data = symbolize(tool_data || {})
81
+ normalized[name.to_s] = {
82
+ flags: normalize_flags(data[:flags]),
83
+ risk_level: normalize_risk_level(data[:risk_level]),
84
+ rationale: data[:rationale].to_s
85
+ }
86
+ end
87
+ end
88
+
89
+ def normalize_flags(flags)
90
+ Array(flags).map(&:to_s).select { |flag| VALID_FLAGS.include?(flag) }.uniq.sort
91
+ end
92
+
93
+ def normalize_risk_level(level)
94
+ risk_level = level.to_s
95
+ VALID_RISK_LEVELS.include?(risk_level) ? risk_level : "low"
96
+ end
97
+
98
+ def symbolize(value)
99
+ case value
100
+ when Hash
101
+ value.each_with_object({}) { |(key, inner), hash| hash[key.to_sym] = symbolize(inner) }
102
+ when Array
103
+ value.map { |item| symbolize(item) }
104
+ else
105
+ value
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "../harness/provider_factory"
6
+ require_relative "../harness/provider_info"
7
+ require_relative "../harness/thinking_depth_manager"
8
+ require_relative "mcp_risk_profile"
9
+
10
+ module Aidp
11
+ module Security
12
+ # Generates an MCP tool risk profile using an AI call at configuration time.
13
+ class McpToolRiskClassifier
14
+ RISK_FLAGS = %w[untrusted_input private_data egress].freeze
15
+ RISK_LEVELS = %w[low medium high].freeze
16
+
17
+ GENERATION_PROMPT = <<~PROMPT
18
+ You are classifying MCP tools for Rule-of-Two security enforcement.
19
+
20
+ For each MCP tool, determine which risk flags should be enabled when that
21
+ tool is available to an agent:
22
+ - untrusted_input: processes or imports untrusted external/user-controlled content
23
+ - private_data: can access secrets, credentials, local files, repos, databases, or other sensitive data
24
+ - egress: can communicate externally, push data, call remote services, or send messages off-machine
25
+
26
+ Also assign a risk_level of low, medium, or high based on the combined impact.
27
+
28
+ Tools to classify:
29
+ {{tools_json}}
30
+
31
+ Respond with ONLY valid JSON in this format:
32
+ {
33
+ "tools": [
34
+ {
35
+ "name": "filesystem",
36
+ "flags": ["private_data"],
37
+ "risk_level": "medium",
38
+ "rationale": "Can read and write local files that may contain secrets."
39
+ }
40
+ ]
41
+ }
42
+ PROMPT
43
+
44
+ attr_reader :config, :project_dir, :provider_factory
45
+
46
+ def initialize(config, project_dir:, provider_factory: nil, provider_info_class: nil, time_source: Time)
47
+ @config = config
48
+ @project_dir = project_dir
49
+ @provider_factory = provider_factory || Aidp::Harness::ProviderFactory.new(config)
50
+ @provider_info_class = provider_info_class || Aidp::Harness::ProviderInfo
51
+ @time_source = time_source
52
+ end
53
+
54
+ def generate!(providers: nil, tier: "mini", force_refresh: false, force_refresh_providers: nil)
55
+ tools = collect_tools(
56
+ providers: providers,
57
+ force_refresh: force_refresh,
58
+ force_refresh_providers: force_refresh_providers
59
+ )
60
+ return write_empty_profile if tools.empty?
61
+
62
+ provider_name, model_name = select_model(tier)
63
+ response = call_ai(provider_name, model_name, build_prompt(tools))
64
+ profile = build_profile(response, model_name, expected_tools: tools)
65
+ profile.save!(project_dir)
66
+ profile
67
+ rescue => e
68
+ Aidp.log_error("security.mcp_classifier", "generation_failed",
69
+ error: e.message,
70
+ error_class: e.class.name)
71
+ raise
72
+ end
73
+
74
+ def collect_tools(providers: nil, force_refresh: false, force_refresh_providers: nil)
75
+ provider_names = Array(providers || config.configured_providers).map(&:to_s)
76
+ force_refresh_provider_names = Array(force_refresh_providers).map(&:to_s)
77
+
78
+ provider_names.each_with_object({}) do |provider_name, tools|
79
+ refresh_provider = refresh_provider?(provider_name, force_refresh, force_refresh_provider_names)
80
+ provider_info = @provider_info_class.new(provider_name, project_dir)
81
+ info = provider_info.info(force_refresh: refresh_provider)
82
+ validate_refreshed_provider_info!(provider_name, info, refresh_provider)
83
+ next unless info&.dig(:mcp_support)
84
+
85
+ enabled_mcp_servers(info).each do |server|
86
+ next if server_name(server).empty?
87
+
88
+ entry = (tools[server_name(server)] ||= {
89
+ name: server_name(server),
90
+ descriptions: [],
91
+ providers: []
92
+ })
93
+ description = server[:description].to_s.strip
94
+ entry[:descriptions] << description unless description.empty?
95
+ entry[:providers] << provider_name
96
+ end
97
+ end.values.map do |tool|
98
+ tool[:descriptions].uniq!
99
+ tool[:providers].uniq!
100
+ tool
101
+ end.sort_by { |tool| tool[:name] }
102
+ end
103
+
104
+ private
105
+
106
+ def write_empty_profile
107
+ profile = McpRiskProfile.new(
108
+ generated_at: @time_source.now.utc.iso8601,
109
+ generator_model: "none",
110
+ tools: {}
111
+ )
112
+ profile.save!(project_dir)
113
+ profile
114
+ end
115
+
116
+ def select_model(tier)
117
+ thinking_manager = Aidp::Harness::ThinkingDepthManager.new(config)
118
+ provider_name, model_name, = thinking_manager.select_model_for_tier(
119
+ tier,
120
+ provider: config.respond_to?(:default_provider) ? config.default_provider : nil
121
+ )
122
+ [provider_name, model_name]
123
+ end
124
+
125
+ def build_prompt(tools)
126
+ prompt = GENERATION_PROMPT.dup
127
+ prompt.gsub!("{{tools_json}}", JSON.pretty_generate(tools))
128
+ prompt
129
+ end
130
+
131
+ def call_ai(provider_name, model_name, prompt)
132
+ provider = provider_factory.create_provider(provider_name, model: model_name, output: nil, prompt: nil)
133
+ provider.send_message(prompt: prompt, session: nil)
134
+ end
135
+
136
+ def build_profile(response, model_name, expected_tools:)
137
+ parsed = parse_response(response)
138
+ classified_tools = index_classified_tools(parsed)
139
+ expected_names = expected_tools.map { |tool| tool[:name] }
140
+
141
+ log_unexpected_tools(classified_tools.keys - expected_names)
142
+
143
+ tools_hash = expected_names.each_with_object({}) do |tool_name, hash|
144
+ tool_profile = classified_tools[tool_name]
145
+ hash[tool_name] = tool_profile || conservative_tool_profile(tool_name)
146
+ end
147
+
148
+ McpRiskProfile.new(
149
+ generated_at: @time_source.now.utc.iso8601,
150
+ generator_model: model_name,
151
+ tools: tools_hash
152
+ )
153
+ end
154
+
155
+ def parse_response(response)
156
+ text = response.is_a?(String) ? response : response.to_s
157
+ json_match = text.match(/\{.*\}/m)
158
+ raise "No JSON found in AI response" unless json_match
159
+
160
+ JSON.parse(json_match[0], symbolize_names: true)
161
+ rescue JSON::ParserError => e
162
+ raise "Invalid JSON in AI response: #{e.message}"
163
+ end
164
+
165
+ def index_classified_tools(parsed)
166
+ Array(parsed[:tools]).each_with_object({}) do |tool, hash|
167
+ name = tool[:name].to_s.strip
168
+ next if name.empty?
169
+
170
+ normalized = normalize_tool_profile(tool)
171
+ hash[name] = normalized if normalized
172
+ end
173
+ end
174
+
175
+ def normalize_tool_profile(tool)
176
+ flags = tool[:flags]
177
+ return unless flags.is_a?(Array) && RISK_LEVELS.include?(tool[:risk_level].to_s)
178
+
179
+ normalized_flags = flags.map(&:to_s).select { |flag| RISK_FLAGS.include?(flag) }.uniq
180
+ return if flags.any? && normalized_flags.empty?
181
+
182
+ {
183
+ flags: normalized_flags,
184
+ risk_level: tool[:risk_level].to_s,
185
+ rationale: tool[:rationale].to_s.strip
186
+ }
187
+ end
188
+
189
+ def conservative_tool_profile(tool_name)
190
+ Aidp.log_warn("security.mcp_classifier", "missing_tool_classification",
191
+ tool_name: tool_name)
192
+
193
+ {
194
+ flags: RISK_FLAGS,
195
+ risk_level: "high",
196
+ rationale: "Conservative fallback: model response omitted or malformed classification for #{tool_name}."
197
+ }
198
+ end
199
+
200
+ def log_unexpected_tools(tool_names)
201
+ return if tool_names.empty?
202
+
203
+ Aidp.log_warn("security.mcp_classifier", "unexpected_tool_classifications",
204
+ tool_names: tool_names)
205
+ end
206
+
207
+ def enabled_mcp_servers(info)
208
+ Array(info[:mcp_servers]).select { |server| server_enabled?(server) }
209
+ end
210
+
211
+ def refresh_provider?(provider_name, force_refresh, force_refresh_provider_names)
212
+ force_refresh || force_refresh_provider_names.include?(provider_name)
213
+ end
214
+
215
+ def validate_refreshed_provider_info!(provider_name, info, refresh_provider)
216
+ return unless refresh_provider
217
+ raise "Provider metadata unavailable for #{provider_name}" unless info
218
+ return unless info[:mcp_support]
219
+ return if info.key?(:mcp_servers)
220
+
221
+ raise "MCP server metadata unavailable for #{provider_name}"
222
+ end
223
+
224
+ def server_enabled?(server)
225
+ server[:enabled] != false
226
+ end
227
+
228
+ def server_name(server)
229
+ server[:name].to_s.strip
230
+ end
231
+ end
232
+ end
233
+ end
@@ -17,7 +17,8 @@ module Aidp
17
17
  # adapter.check_agent_call_allowed!(operation: :git_push)
18
18
  # adapter.end_work_unit
19
19
  class WorkLoopAdapter
20
- attr_reader :project_dir, :config, :current_work_unit_id, :current_state
20
+ attr_reader :project_dir, :config, :current_work_unit_id, :current_state, :mcp_risk_profile
21
+ MCP_CONSERVATIVE_FLAGS = %i[untrusted_input private_data egress].freeze
21
22
 
22
23
  # Sources of untrusted input that trigger the untrusted_input flag
23
24
  UNTRUSTED_SOURCES = %w[
@@ -42,11 +43,13 @@ module Aidp
42
43
  issue_comment
43
44
  ].freeze
44
45
 
45
- def initialize(project_dir:, config: nil, enforcer: nil, secrets_proxy: nil)
46
+ def initialize(project_dir:, config: nil, enforcer: nil, secrets_proxy: nil, provider_info_class: nil)
46
47
  @project_dir = project_dir
47
48
  @config = config || load_security_config
48
49
  @enforcer = enforcer || Aidp::Security.enforcer
49
50
  @secrets_proxy = secrets_proxy || Aidp::Security.secrets_proxy
51
+ @provider_info_class = provider_info_class || Aidp::Harness::ProviderInfo
52
+ @mcp_risk_profile = load_mcp_risk_profile
50
53
  @current_work_unit_id = nil
51
54
  @current_state = nil
52
55
  end
@@ -131,6 +134,40 @@ module Aidp
131
134
  @current_state
132
135
  end
133
136
 
137
+ # Apply deterministic MCP tool risk flags generated during configuration.
138
+ # This uses the stored profile and does not perform any AI calls at runtime.
139
+ def apply_mcp_tool_risk!(tool_name)
140
+ return @current_state unless enabled? && @current_state
141
+
142
+ tool_profile = @mcp_risk_profile.tool(tool_name)
143
+ return apply_unclassified_mcp_tool_risk!(tool_name) unless tool_profile
144
+
145
+ enable_mcp_tool_flags(tool_name, tool_profile[:flags])
146
+ end
147
+
148
+ # Apply deterministic MCP tool risk flags for all MCP servers configured on
149
+ # the selected provider. This reads only persisted provider metadata so the
150
+ # work loop never re-introspects provider CLIs at runtime.
151
+ def apply_provider_mcp_tool_risk!(provider_name, force_refresh: false)
152
+ return @current_state unless enabled? && @current_state
153
+
154
+ if force_refresh
155
+ Aidp.log_debug("security.adapter", "ignored_provider_mcp_refresh_request",
156
+ provider: provider_name)
157
+ end
158
+
159
+ provider_mcp_server_names(provider_name).each do |tool_name|
160
+ apply_mcp_tool_risk!(tool_name)
161
+ end
162
+
163
+ @current_state
164
+ rescue => e
165
+ Aidp.log_warn("security.adapter", "mcp_provider_lookup_failed",
166
+ provider: provider_name,
167
+ error: e.message)
168
+ apply_unknown_mcp_risk!(provider_name)
169
+ end
170
+
134
171
  # Request credentials through the secrets proxy
135
172
  # This enables the private_data flag and returns a short-lived token
136
173
  # @param secret_name [String] The registered secret name
@@ -219,6 +256,60 @@ module Aidp
219
256
  {} # Fallback to empty config
220
257
  end
221
258
 
259
+ def load_mcp_risk_profile
260
+ Aidp::Security::McpRiskProfile.load(@project_dir)
261
+ rescue
262
+ Aidp::Security::McpRiskProfile.new(tools: {})
263
+ end
264
+
265
+ def provider_mcp_server_names(provider_name)
266
+ provider_info = @provider_info_class.new(provider_name, project_dir)
267
+ info = provider_info.load_info
268
+ raise "Provider metadata unavailable for #{provider_name}" unless info
269
+
270
+ return [] unless info&.dig(:mcp_support)
271
+ raise "MCP server metadata unavailable for #{provider_name}" unless info.key?(:mcp_servers)
272
+
273
+ enabled_mcp_servers(info).filter_map do |server|
274
+ name = server_name(server)
275
+ name unless name.empty?
276
+ end.uniq
277
+ end
278
+
279
+ def enabled_mcp_servers(info)
280
+ Array(info[:mcp_servers]).select { |server| server_enabled?(server) }
281
+ end
282
+
283
+ def server_enabled?(server)
284
+ server[:enabled] != false
285
+ end
286
+
287
+ def server_name(server)
288
+ server[:name].to_s.strip
289
+ end
290
+
291
+ def apply_unknown_mcp_risk!(provider_name)
292
+ MCP_CONSERVATIVE_FLAGS.each do |flag|
293
+ @current_state.enable(flag, source: "mcp_provider:#{provider_name}:unknown_tools")
294
+ end
295
+
296
+ @current_state
297
+ end
298
+
299
+ def apply_unclassified_mcp_tool_risk!(tool_name)
300
+ Aidp.log_warn("security.adapter", "mcp_tool_unclassified",
301
+ tool_name: tool_name)
302
+ enable_mcp_tool_flags(tool_name, MCP_CONSERVATIVE_FLAGS, source_suffix: ":unclassified")
303
+ end
304
+
305
+ def enable_mcp_tool_flags(tool_name, flags, source_suffix: "")
306
+ Array(flags).each do |flag|
307
+ @current_state.enable(flag.to_sym, source: "mcp_tool:#{tool_name}#{source_suffix}")
308
+ end
309
+
310
+ @current_state
311
+ end
312
+
222
313
  # Detect untrusted input sources in the context
223
314
  def detect_and_enable_untrusted_input(context)
224
315
  sources = []
data/lib/aidp/security.rb CHANGED
@@ -16,6 +16,8 @@ require_relative "security/trifecta_state"
16
16
  require_relative "security/rule_of_two_enforcer"
17
17
  require_relative "security/secrets_registry"
18
18
  require_relative "security/secrets_proxy"
19
+ require_relative "security/mcp_risk_profile"
20
+ require_relative "security/mcp_tool_risk_classifier"
19
21
  require_relative "security/work_loop_adapter"
20
22
  require_relative "security/watch_mode_handler"
21
23
 
@@ -9,6 +9,7 @@ require "json"
9
9
  require "ostruct"
10
10
  require_relative "in_memory_config_adapter"
11
11
  require_relative "in_memory_config_manager"
12
+ require_relative "../security"
12
13
 
13
14
  module Aidp
14
15
  module Setup
@@ -1993,11 +1994,23 @@ module Aidp
1993
1994
  def save_config(yaml_content)
1994
1995
  Aidp::ConfigPaths.ensure_config_dir(project_dir)
1995
1996
  File.write(config_path, yaml_content)
1997
+ generate_mcp_risk_profile
1996
1998
 
1997
1999
  # Generate devcontainer if managed
1998
2000
  generate_devcontainer_file
1999
2001
  end
2000
2002
 
2003
+ def generate_mcp_risk_profile
2004
+ classifier = Aidp::Security::McpToolRiskClassifier.new(
2005
+ build_in_memory_config_adapter,
2006
+ project_dir: project_dir
2007
+ )
2008
+ classifier.generate!
2009
+ rescue => e
2010
+ @warnings << "Failed to generate MCP risk profile: #{e.message}"
2011
+ Aidp.log_warn("setup_wizard", "mcp_risk_profile_generation_failed", error: e.message)
2012
+ end
2013
+
2001
2014
  def display_warnings
2002
2015
  return if @warnings.empty?
2003
2016
 
data/lib/aidp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Aidp
4
- VERSION = "0.41.0"
4
+ VERSION = "0.43.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aidp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.41.0
4
+ version: 0.43.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -407,6 +407,7 @@ files:
407
407
  - lib/aidp/execute/interactive_repl.rb
408
408
  - lib/aidp/execute/persistent_tasklist.rb
409
409
  - lib/aidp/execute/progress.rb
410
+ - lib/aidp/execute/project_knowledge_manager.rb
410
411
  - lib/aidp/execute/prompt_evaluator.rb
411
412
  - lib/aidp/execute/prompt_manager.rb
412
413
  - lib/aidp/execute/repl_macros.rb
@@ -526,6 +527,7 @@ files:
526
527
  - lib/aidp/pr_worktree_manager.rb
527
528
  - lib/aidp/prompt_optimization/context_composer.rb
528
529
  - lib/aidp/prompt_optimization/optimizer.rb
530
+ - lib/aidp/prompt_optimization/project_knowledge_indexer.rb
529
531
  - lib/aidp/prompt_optimization/prompt_builder.rb
530
532
  - lib/aidp/prompt_optimization/relevance_scorer.rb
531
533
  - lib/aidp/prompt_optimization/source_code_fragmenter.rb
@@ -539,6 +541,8 @@ files:
539
541
  - lib/aidp/rescue_logging.rb
540
542
  - lib/aidp/safe_directory.rb
541
543
  - lib/aidp/security.rb
544
+ - lib/aidp/security/mcp_risk_profile.rb
545
+ - lib/aidp/security/mcp_tool_risk_classifier.rb
542
546
  - lib/aidp/security/rule_of_two_enforcer.rb
543
547
  - lib/aidp/security/secrets_proxy.rb
544
548
  - lib/aidp/security/secrets_registry.rb