aidp 0.42.0 → 0.44.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.rb +2 -0
- 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 +27 -0
- data/lib/aidp/version.rb +1 -1
- data/lib/aidp/watch/plan_generator.rb +2 -1
- data/lib/aidp/watch/plan_processor.rb +472 -18
- data/lib/aidp/watch/repository_client.rb +194 -0
- data/lib/aidp/watch/runner.rb +117 -9
- data/lib/aidp/watch/state_store.rb +41 -1
- data/lib/aidp/watch/sub_issue_creator.rb +133 -19
- 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: d898c7dea670e05503b8777e0907e0d0f975d398fd3cf80d9ddc659e8806070c
|
|
4
|
+
data.tar.gz: c25d8e50f56ce0e89f93c3e5fea9d4fb0ca6d540c19ac5fd3d2710d38be57290
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ec307da67468acc15043f72c0295f443a7650a4312d1767f12629fbf582d02354a76cf2476081a313cb434c48eb63f5ec06d6d7d90f39d00d972342725d0dbe5
|
|
7
|
+
data.tar.gz: 7bbb9cbfff2543d753b56ca940c19745b0b4678d9e4825c418639c5ec2cbeeac99069de846e75e8c8ef05a8d83b825d13d7e99b056a462e07a89c1f56a2cb96a
|
|
@@ -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.rb
CHANGED
|
@@ -197,7 +197,9 @@ module Aidp
|
|
|
197
197
|
polling_interval: 30,
|
|
198
198
|
labels: {
|
|
199
199
|
plan_trigger: "aidp-plan",
|
|
200
|
+
project_trigger: "aidp-project",
|
|
200
201
|
build_trigger: "aidp-build",
|
|
202
|
+
blocked_trigger: "aidp-blocked",
|
|
201
203
|
review_trigger: "aidp-review",
|
|
202
204
|
fix_ci_trigger: "aidp-fix-ci",
|
|
203
205
|
change_request_trigger: "aidp-request-changes",
|
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
|
|
@@ -1648,6 +1649,11 @@ module Aidp
|
|
|
1648
1649
|
existing[:plan_trigger] || "aidp-plan"
|
|
1649
1650
|
)
|
|
1650
1651
|
|
|
1652
|
+
project_trigger = ask_with_default(
|
|
1653
|
+
"Label to trigger project planning",
|
|
1654
|
+
existing[:project_trigger] || "aidp-project"
|
|
1655
|
+
)
|
|
1656
|
+
|
|
1651
1657
|
needs_input = ask_with_default(
|
|
1652
1658
|
"Label for plans needing user input",
|
|
1653
1659
|
existing[:needs_input] || "aidp-needs-input"
|
|
@@ -1663,6 +1669,11 @@ module Aidp
|
|
|
1663
1669
|
existing[:build_trigger] || "aidp-build"
|
|
1664
1670
|
)
|
|
1665
1671
|
|
|
1672
|
+
blocked_trigger = ask_with_default(
|
|
1673
|
+
"Label for blocked project sub-issues",
|
|
1674
|
+
existing[:blocked_trigger] || "aidp-blocked"
|
|
1675
|
+
)
|
|
1676
|
+
|
|
1666
1677
|
review_trigger = ask_with_default(
|
|
1667
1678
|
"Label to trigger code review",
|
|
1668
1679
|
existing[:review_trigger] || "aidp-review"
|
|
@@ -1685,9 +1696,11 @@ module Aidp
|
|
|
1685
1696
|
|
|
1686
1697
|
set(%i[watch labels], {
|
|
1687
1698
|
plan_trigger: plan_trigger,
|
|
1699
|
+
project_trigger: project_trigger,
|
|
1688
1700
|
needs_input: needs_input,
|
|
1689
1701
|
ready_to_build: ready_to_build,
|
|
1690
1702
|
build_trigger: build_trigger,
|
|
1703
|
+
blocked_trigger: blocked_trigger,
|
|
1691
1704
|
review_trigger: review_trigger,
|
|
1692
1705
|
ci_fix_trigger: ci_fix_trigger,
|
|
1693
1706
|
auto_trigger: auto_trigger,
|
|
@@ -1855,9 +1868,11 @@ module Aidp
|
|
|
1855
1868
|
def collect_required_labels(labels_config)
|
|
1856
1869
|
default_colors = {
|
|
1857
1870
|
plan_trigger: "0E8A16", # Green
|
|
1871
|
+
project_trigger: "0052CC", # Deep blue
|
|
1858
1872
|
needs_input: "D93F0B", # Red
|
|
1859
1873
|
ready_to_build: "0075CA", # Blue
|
|
1860
1874
|
build_trigger: "5319E7", # Purple
|
|
1875
|
+
blocked_trigger: "B60205", # Dark red
|
|
1861
1876
|
review_trigger: "FBCA04", # Yellow
|
|
1862
1877
|
ci_fix_trigger: "D93F0B", # Red
|
|
1863
1878
|
auto_trigger: "0C8BD6", # Blue (distinct from build)
|
|
@@ -1993,11 +2008,23 @@ module Aidp
|
|
|
1993
2008
|
def save_config(yaml_content)
|
|
1994
2009
|
Aidp::ConfigPaths.ensure_config_dir(project_dir)
|
|
1995
2010
|
File.write(config_path, yaml_content)
|
|
2011
|
+
generate_mcp_risk_profile
|
|
1996
2012
|
|
|
1997
2013
|
# Generate devcontainer if managed
|
|
1998
2014
|
generate_devcontainer_file
|
|
1999
2015
|
end
|
|
2000
2016
|
|
|
2017
|
+
def generate_mcp_risk_profile
|
|
2018
|
+
classifier = Aidp::Security::McpToolRiskClassifier.new(
|
|
2019
|
+
build_in_memory_config_adapter,
|
|
2020
|
+
project_dir: project_dir
|
|
2021
|
+
)
|
|
2022
|
+
classifier.generate!
|
|
2023
|
+
rescue => e
|
|
2024
|
+
@warnings << "Failed to generate MCP risk profile: #{e.message}"
|
|
2025
|
+
Aidp.log_warn("setup_wizard", "mcp_risk_profile_generation_failed", error: e.message)
|
|
2026
|
+
end
|
|
2027
|
+
|
|
2001
2028
|
def display_warnings
|
|
2002
2029
|
return if @warnings.empty?
|
|
2003
2030
|
|
data/lib/aidp/version.rb
CHANGED
|
@@ -27,7 +27,7 @@ module Aidp
|
|
|
27
27
|
PROMPT
|
|
28
28
|
|
|
29
29
|
HIERARCHICAL_PROVIDER_PROMPT = <<~PROMPT
|
|
30
|
-
You are AIDP's planning specialist for large projects. Read the GitHub issue and existing comments.
|
|
30
|
+
You are AIDP's project planning specialist for large projects. Read the GitHub issue and existing comments.
|
|
31
31
|
This is a LARGE project that should be broken down into independent sub-issues.
|
|
32
32
|
|
|
33
33
|
Analyze the requirements and break them into logical sub-tasks that can be worked on independently.
|
|
@@ -59,6 +59,7 @@ module Aidp
|
|
|
59
59
|
- Sub-issues should be sized for 1-3 days of work
|
|
60
60
|
- Include 3-8 sub-issues for a large project
|
|
61
61
|
- Be specific about skills needed (avoid generic terms)
|
|
62
|
+
- Prefer leaf-node tasks first so dependency order is explicit and mechanically executable
|
|
62
63
|
- Only create sub-issues if the project truly warrants it (complexity, multiple components, etc.)
|
|
63
64
|
PROMPT
|
|
64
65
|
|