aidp 0.42.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.
- checksums.yaml +4 -4
- data/lib/aidp/cli/providers_command.rb +16 -1
- data/lib/aidp/config_paths.rb +1 -0
- data/lib/aidp/execute/work_loop_runner.rb +1 -0
- data/lib/aidp/security/mcp_risk_profile.rb +110 -0
- data/lib/aidp/security/mcp_tool_risk_classifier.rb +233 -0
- data/lib/aidp/security/work_loop_adapter.rb +93 -2
- data/lib/aidp/security.rb +2 -0
- data/lib/aidp/setup/wizard.rb +13 -0
- data/lib/aidp/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 013dc044889cc320404776d0691d2091f3cddda0d9ba854dc0afb92312396336
|
|
4
|
+
data.tar.gz: bb1c57bf8e7d87dfa7d7b169d69646ac76c0cb14fe2f72c70ef02b8543b43494
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c7ea760cd1d62424ada60f64acc356cdf4a82db6e2166c203d9d41c489fed936a704afaee17e9a20d588fbf0ead8ebd816a991a3fa01d87644939c872b104b12
|
|
7
|
+
data.tar.gz: 9c3b34d7474a2f9e899bf50d9b61d1edcb67a1b4a7b41d78abf810517db8cb2082fe889ec7b1fda5266276b6f1406e577644f2ddca16142bcf66e66e79b1e338
|
|
@@ -6,6 +6,7 @@ require "tty-spinner"
|
|
|
6
6
|
require_relative "../harness/provider_info"
|
|
7
7
|
require_relative "../harness/capability_registry"
|
|
8
8
|
require_relative "../harness/config_manager"
|
|
9
|
+
require_relative "../security"
|
|
9
10
|
|
|
10
11
|
module Aidp
|
|
11
12
|
class CLI
|
|
@@ -21,12 +22,13 @@ module Aidp
|
|
|
21
22
|
class ProvidersCommand
|
|
22
23
|
include Aidp::MessageDisplay
|
|
23
24
|
|
|
24
|
-
def initialize(prompt: TTY::Prompt.new, provider_info_class: nil, capability_registry_class: nil, config_manager_class: nil, project_dir: nil)
|
|
25
|
+
def initialize(prompt: TTY::Prompt.new, provider_info_class: nil, capability_registry_class: nil, config_manager_class: nil, project_dir: nil, mcp_tool_risk_classifier_class: nil)
|
|
25
26
|
@prompt = prompt
|
|
26
27
|
@provider_info_class = provider_info_class || Aidp::Harness::ProviderInfo
|
|
27
28
|
@capability_registry_class = capability_registry_class || Aidp::Harness::CapabilityRegistry
|
|
28
29
|
@config_manager_class = config_manager_class || Aidp::Harness::ConfigManager
|
|
29
30
|
@project_dir = project_dir || Dir.pwd
|
|
31
|
+
@mcp_tool_risk_classifier_class = mcp_tool_risk_classifier_class || Aidp::Security::McpToolRiskClassifier
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
# Main entry point for providers info/refresh subcommands
|
|
@@ -198,10 +200,23 @@ module Aidp
|
|
|
198
200
|
end
|
|
199
201
|
end
|
|
200
202
|
|
|
203
|
+
refresh_mcp_risk_profile(config_manager, providers_to_refresh)
|
|
204
|
+
|
|
201
205
|
display_message("\n" + "=" * 60, type: :muted)
|
|
202
206
|
display_message("Refresh complete", type: :highlight)
|
|
203
207
|
end
|
|
204
208
|
|
|
209
|
+
def refresh_mcp_risk_profile(config_manager, providers_to_refresh)
|
|
210
|
+
classifier = @mcp_tool_risk_classifier_class.new(config_manager, project_dir: @project_dir)
|
|
211
|
+
classifier.generate!(
|
|
212
|
+
providers: config_manager.provider_names,
|
|
213
|
+
force_refresh_providers: providers_to_refresh
|
|
214
|
+
)
|
|
215
|
+
display_message("✓ MCP risk profile refreshed", type: :success)
|
|
216
|
+
rescue => e
|
|
217
|
+
display_message("⚠ MCP risk profile refresh failed: #{e.message}", type: :warning)
|
|
218
|
+
end
|
|
219
|
+
|
|
205
220
|
def display_help
|
|
206
221
|
display_message("\nUsage: aidp providers <subcommand> [options]", type: :info)
|
|
207
222
|
display_message("\nSubcommands:", type: :info)
|
data/lib/aidp/config_paths.rb
CHANGED
|
@@ -32,6 +32,7 @@ module Aidp
|
|
|
32
32
|
# Security module paths
|
|
33
33
|
def self.security_dir(project_dir = Dir.pwd) = File.join(aidp_dir(project_dir), "security")
|
|
34
34
|
def self.secrets_registry_file(project_dir = Dir.pwd) = File.join(security_dir(project_dir), "secrets_registry.json")
|
|
35
|
+
def self.mcp_risk_profile_file(project_dir = Dir.pwd) = File.join(security_dir(project_dir), "mcp_risk_profile.yml")
|
|
35
36
|
|
|
36
37
|
# Database path
|
|
37
38
|
def self.database_file(project_dir = Dir.pwd) = File.join(aidp_dir(project_dir), "aidp.db")
|
|
@@ -1094,6 +1094,7 @@ module Aidp
|
|
|
1094
1094
|
# Execute with sanitized environment (secrets stripped) when security is enabled
|
|
1095
1095
|
# This ensures agent processes cannot access registered secrets directly
|
|
1096
1096
|
execute_block = lambda do
|
|
1097
|
+
@security_adapter.apply_provider_mcp_tool_risk!(provider_name)
|
|
1097
1098
|
@provider_manager.execute_with_provider(
|
|
1098
1099
|
provider_name,
|
|
1099
1100
|
full_prompt,
|
|
@@ -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
|
|
data/lib/aidp/setup/wizard.rb
CHANGED
|
@@ -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
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.
|
|
4
|
+
version: 0.43.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bart Agapinan
|
|
@@ -541,6 +541,8 @@ files:
|
|
|
541
541
|
- lib/aidp/rescue_logging.rb
|
|
542
542
|
- lib/aidp/safe_directory.rb
|
|
543
543
|
- lib/aidp/security.rb
|
|
544
|
+
- lib/aidp/security/mcp_risk_profile.rb
|
|
545
|
+
- lib/aidp/security/mcp_tool_risk_classifier.rb
|
|
544
546
|
- lib/aidp/security/rule_of_two_enforcer.rb
|
|
545
547
|
- lib/aidp/security/secrets_proxy.rb
|
|
546
548
|
- lib/aidp/security/secrets_registry.rb
|