aidp 0.33.0 → 0.34.1
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/README.md +35 -0
- data/lib/aidp/analyze/tree_sitter_scan.rb +3 -0
- data/lib/aidp/cli/eval_command.rb +399 -0
- data/lib/aidp/cli/harness_command.rb +1 -1
- data/lib/aidp/cli/security_command.rb +416 -0
- data/lib/aidp/cli/tools_command.rb +6 -4
- data/lib/aidp/cli.rb +170 -3
- data/lib/aidp/concurrency/exec.rb +3 -0
- data/lib/aidp/config.rb +113 -0
- data/lib/aidp/config_paths.rb +20 -0
- data/lib/aidp/daemon/runner.rb +8 -4
- data/lib/aidp/errors.rb +134 -0
- data/lib/aidp/evaluations/context_capture.rb +205 -0
- data/lib/aidp/evaluations/evaluation_record.rb +114 -0
- data/lib/aidp/evaluations/evaluation_storage.rb +250 -0
- data/lib/aidp/evaluations.rb +23 -0
- data/lib/aidp/execute/async_work_loop_runner.rb +4 -1
- data/lib/aidp/execute/interactive_repl.rb +6 -2
- data/lib/aidp/execute/prompt_evaluator.rb +359 -0
- data/lib/aidp/execute/repl_macros.rb +100 -1
- data/lib/aidp/execute/work_loop_runner.rb +399 -47
- data/lib/aidp/execute/work_loop_state.rb +4 -1
- data/lib/aidp/execute/workflow_selector.rb +3 -0
- data/lib/aidp/harness/ai_decision_engine.rb +79 -0
- data/lib/aidp/harness/capability_registry.rb +2 -0
- data/lib/aidp/harness/condition_detector.rb +3 -0
- data/lib/aidp/harness/config_loader.rb +3 -0
- data/lib/aidp/harness/enhanced_runner.rb +14 -11
- data/lib/aidp/harness/error_handler.rb +3 -0
- data/lib/aidp/harness/provider_factory.rb +3 -0
- data/lib/aidp/harness/provider_manager.rb +6 -0
- data/lib/aidp/harness/runner.rb +5 -1
- data/lib/aidp/harness/state/persistence.rb +3 -0
- data/lib/aidp/harness/state_manager.rb +3 -0
- data/lib/aidp/harness/status_display.rb +28 -20
- data/lib/aidp/harness/thinking_depth_manager.rb +32 -32
- data/lib/aidp/harness/ui/enhanced_tui.rb +4 -0
- data/lib/aidp/harness/ui/enhanced_workflow_selector.rb +4 -0
- data/lib/aidp/harness/ui/error_handler.rb +3 -0
- data/lib/aidp/harness/ui/job_monitor.rb +4 -0
- data/lib/aidp/harness/ui/navigation/submenu.rb +2 -0
- data/lib/aidp/harness/ui/navigation/workflow_selector.rb +6 -0
- data/lib/aidp/harness/ui/spinner_helper.rb +3 -0
- data/lib/aidp/harness/ui/workflow_controller.rb +3 -0
- data/lib/aidp/harness/ui.rb +11 -0
- data/lib/aidp/harness/user_interface.rb +3 -0
- data/lib/aidp/loader.rb +2 -2
- data/lib/aidp/logger.rb +3 -0
- data/lib/aidp/message_display.rb +31 -0
- data/lib/aidp/pr_worktree_manager.rb +18 -6
- data/lib/aidp/provider_manager.rb +3 -0
- data/lib/aidp/providers/base.rb +2 -0
- data/lib/aidp/security/rule_of_two_enforcer.rb +210 -0
- data/lib/aidp/security/secrets_proxy.rb +328 -0
- data/lib/aidp/security/secrets_registry.rb +227 -0
- data/lib/aidp/security/trifecta_state.rb +220 -0
- data/lib/aidp/security/watch_mode_handler.rb +306 -0
- data/lib/aidp/security/work_loop_adapter.rb +277 -0
- data/lib/aidp/security.rb +56 -0
- data/lib/aidp/setup/wizard.rb +4 -2
- data/lib/aidp/version.rb +1 -1
- data/lib/aidp/watch/auto_merger.rb +274 -0
- data/lib/aidp/watch/auto_pr_processor.rb +125 -7
- data/lib/aidp/watch/build_processor.rb +16 -1
- data/lib/aidp/watch/change_request_processor.rb +680 -286
- data/lib/aidp/watch/ci_fix_processor.rb +262 -4
- data/lib/aidp/watch/feedback_collector.rb +191 -0
- data/lib/aidp/watch/hierarchical_pr_strategy.rb +256 -0
- data/lib/aidp/watch/implementation_verifier.rb +142 -1
- data/lib/aidp/watch/plan_generator.rb +70 -13
- data/lib/aidp/watch/plan_processor.rb +12 -5
- data/lib/aidp/watch/projects_processor.rb +286 -0
- data/lib/aidp/watch/repository_client.rb +861 -53
- data/lib/aidp/watch/review_processor.rb +33 -6
- data/lib/aidp/watch/runner.rb +51 -11
- data/lib/aidp/watch/state_store.rb +233 -0
- data/lib/aidp/watch/sub_issue_creator.rb +221 -0
- data/lib/aidp/workflows/guided_agent.rb +4 -0
- data/lib/aidp/workstream_executor.rb +3 -0
- data/lib/aidp/worktree.rb +61 -11
- data/lib/aidp/worktree_branch_manager.rb +347 -101
- data/templates/implementation/iterative_implementation.md +46 -3
- metadata +21 -1
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aidp
|
|
4
|
+
module Security
|
|
5
|
+
# Handles security policy violations in watch mode with fail-forward logic
|
|
6
|
+
# When a Rule of Two violation occurs, attempts to find alternative approaches
|
|
7
|
+
# using AGD/ZFC before failing. If no path forward, adds a comment and label.
|
|
8
|
+
#
|
|
9
|
+
# Fail-forward flow:
|
|
10
|
+
# 1. Security violation detected
|
|
11
|
+
# 2. Attempt to convert to compliant operation (up to max_retry_attempts)
|
|
12
|
+
# - Try using secrets proxy instead of direct credential access
|
|
13
|
+
# - Try sanitizing untrusted input
|
|
14
|
+
# - Try deferring egress operations
|
|
15
|
+
# 3. If all attempts fail, add PR/issue comment and aidp-needs-input label
|
|
16
|
+
class WatchModeHandler
|
|
17
|
+
DEFAULT_MAX_RETRY_ATTEMPTS = 3
|
|
18
|
+
DEFAULT_NEEDS_INPUT_LABEL = "aidp-needs-input"
|
|
19
|
+
|
|
20
|
+
attr_reader :config, :repository_client
|
|
21
|
+
|
|
22
|
+
def initialize(repository_client:, config: {})
|
|
23
|
+
@repository_client = repository_client
|
|
24
|
+
@config = normalize_config(config)
|
|
25
|
+
@retry_counts = {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Handle a security policy violation
|
|
29
|
+
# @param violation [PolicyViolation] The violation that occurred
|
|
30
|
+
# @param context [Hash] Context about the operation
|
|
31
|
+
# - :issue_number or :pr_number - The issue/PR number
|
|
32
|
+
# - :work_unit_id - The work unit identifier
|
|
33
|
+
# - :operation - The operation that was attempted
|
|
34
|
+
# @return [Hash] Result of handling: { recovered: bool, action: symbol, message: string }
|
|
35
|
+
def handle_violation(violation, context:)
|
|
36
|
+
work_unit_id = context[:work_unit_id] || "unknown"
|
|
37
|
+
issue_or_pr_number = context[:issue_number] || context[:pr_number]
|
|
38
|
+
|
|
39
|
+
Aidp.log_debug("security.watch_handler", "handling_violation",
|
|
40
|
+
work_unit_id: work_unit_id,
|
|
41
|
+
flag: violation.flag,
|
|
42
|
+
source: violation.source)
|
|
43
|
+
|
|
44
|
+
# Increment retry count for this work unit
|
|
45
|
+
@retry_counts[work_unit_id] ||= 0
|
|
46
|
+
@retry_counts[work_unit_id] += 1
|
|
47
|
+
retry_count = @retry_counts[work_unit_id]
|
|
48
|
+
|
|
49
|
+
if retry_count <= max_retry_attempts
|
|
50
|
+
# Attempt to find alternative approach
|
|
51
|
+
result = attempt_fail_forward(violation, context, retry_count)
|
|
52
|
+
|
|
53
|
+
if result[:recovered]
|
|
54
|
+
Aidp.log_info("security.watch_handler", "violation_recovered",
|
|
55
|
+
work_unit_id: work_unit_id,
|
|
56
|
+
attempt: retry_count,
|
|
57
|
+
strategy: result[:strategy])
|
|
58
|
+
return result
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Not yet at max retries, will try again
|
|
62
|
+
Aidp.log_debug("security.watch_handler", "fail_forward_attempt_failed",
|
|
63
|
+
work_unit_id: work_unit_id,
|
|
64
|
+
attempt: retry_count,
|
|
65
|
+
max_attempts: max_retry_attempts)
|
|
66
|
+
|
|
67
|
+
{
|
|
68
|
+
recovered: false,
|
|
69
|
+
action: :retry,
|
|
70
|
+
message: "Security violation, attempting alternative approach (#{retry_count}/#{max_retry_attempts})",
|
|
71
|
+
retry_count: retry_count
|
|
72
|
+
}
|
|
73
|
+
else
|
|
74
|
+
# Max retries exceeded - add comment and label
|
|
75
|
+
Aidp.log_warn("security.watch_handler", "max_retries_exceeded",
|
|
76
|
+
work_unit_id: work_unit_id,
|
|
77
|
+
retry_count: retry_count)
|
|
78
|
+
|
|
79
|
+
if issue_or_pr_number
|
|
80
|
+
add_security_comment_and_label(violation, context, issue_or_pr_number)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Clear retry count
|
|
84
|
+
@retry_counts.delete(work_unit_id)
|
|
85
|
+
|
|
86
|
+
{
|
|
87
|
+
recovered: false,
|
|
88
|
+
action: :fail,
|
|
89
|
+
message: "Security policy violation cannot be resolved automatically. Manual intervention required.",
|
|
90
|
+
needs_input: true
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Reset retry count for a work unit (call on success or explicit reset)
|
|
96
|
+
def reset_retry_count(work_unit_id)
|
|
97
|
+
@retry_counts.delete(work_unit_id)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Check if security handling is enabled
|
|
101
|
+
def enabled?
|
|
102
|
+
@config[:fail_forward_enabled] != false
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def normalize_config(config)
|
|
108
|
+
{
|
|
109
|
+
max_retry_attempts: config[:max_retry_attempts] || config["max_retry_attempts"] || DEFAULT_MAX_RETRY_ATTEMPTS,
|
|
110
|
+
fail_forward_enabled: config.fetch(:fail_forward_enabled, config.fetch("fail_forward_enabled", true)),
|
|
111
|
+
needs_input_label: config[:needs_input_label] || config["needs_input_label"] || DEFAULT_NEEDS_INPUT_LABEL
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def max_retry_attempts
|
|
116
|
+
@config[:max_retry_attempts]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def needs_input_label
|
|
120
|
+
@config[:needs_input_label]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Attempt to find an alternative approach that doesn't violate Rule of Two
|
|
124
|
+
# Returns { recovered: bool, strategy: symbol, ... }
|
|
125
|
+
def attempt_fail_forward(violation, context, attempt_number)
|
|
126
|
+
# Determine which mitigation strategy to try based on the violation
|
|
127
|
+
strategies = build_mitigation_strategies(violation, context)
|
|
128
|
+
|
|
129
|
+
if attempt_number <= strategies.length
|
|
130
|
+
strategy = strategies[attempt_number - 1]
|
|
131
|
+
Aidp.log_debug("security.watch_handler", "trying_strategy",
|
|
132
|
+
strategy: strategy[:name],
|
|
133
|
+
attempt: attempt_number)
|
|
134
|
+
|
|
135
|
+
result = execute_strategy(strategy, violation, context)
|
|
136
|
+
return result if result[:recovered]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
{recovered: false, strategy: nil}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Build ordered list of mitigation strategies based on the violation type
|
|
143
|
+
def build_mitigation_strategies(violation, context)
|
|
144
|
+
strategies = []
|
|
145
|
+
|
|
146
|
+
case violation.flag
|
|
147
|
+
when :private_data
|
|
148
|
+
# Try using secrets proxy instead of direct access
|
|
149
|
+
strategies << {
|
|
150
|
+
name: :use_secrets_proxy,
|
|
151
|
+
description: "Route credential access through secrets proxy",
|
|
152
|
+
action: :convert_to_proxy_access
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
when :egress
|
|
156
|
+
# Try deferring egress operations
|
|
157
|
+
strategies << {
|
|
158
|
+
name: :defer_egress,
|
|
159
|
+
description: "Queue egress operation for later execution",
|
|
160
|
+
action: :queue_for_later
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
when :untrusted_input
|
|
164
|
+
# Try sanitizing the input
|
|
165
|
+
strategies << {
|
|
166
|
+
name: :sanitize_input,
|
|
167
|
+
description: "Sanitize untrusted input before processing",
|
|
168
|
+
action: :sanitize
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Add generic strategies
|
|
173
|
+
strategies << {
|
|
174
|
+
name: :use_deterministic_unit,
|
|
175
|
+
description: "Convert to deterministic unit (no agent call)",
|
|
176
|
+
action: :convert_to_deterministic
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
strategies << {
|
|
180
|
+
name: :request_trusted_context,
|
|
181
|
+
description: "Request elevated trust context",
|
|
182
|
+
action: :request_trust
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
strategies
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Execute a mitigation strategy
|
|
189
|
+
# In MVP, these strategies log the attempt but don't actually recover
|
|
190
|
+
# Future: integrate with AGD/ZFC for intelligent conversion
|
|
191
|
+
def execute_strategy(strategy, violation, context)
|
|
192
|
+
case strategy[:action]
|
|
193
|
+
when :convert_to_proxy_access
|
|
194
|
+
# Check if we can use secrets proxy
|
|
195
|
+
# MVP: Just check if proxy is available, don't actually convert
|
|
196
|
+
begin
|
|
197
|
+
Aidp::Security.secrets_proxy
|
|
198
|
+
rescue
|
|
199
|
+
false
|
|
200
|
+
end
|
|
201
|
+
{recovered: false, strategy: strategy[:name], reason: "Proxy conversion not yet implemented"}
|
|
202
|
+
|
|
203
|
+
when :queue_for_later
|
|
204
|
+
# Queue egress for manual execution
|
|
205
|
+
# MVP: Log the operation for manual review
|
|
206
|
+
Aidp.log_info("security.watch_handler", "egress_queued",
|
|
207
|
+
work_unit_id: context[:work_unit_id],
|
|
208
|
+
operation: context[:operation])
|
|
209
|
+
{recovered: false, strategy: strategy[:name], reason: "Egress queued for manual review"}
|
|
210
|
+
|
|
211
|
+
when :sanitize
|
|
212
|
+
# Input sanitization would require AGD/ZFC
|
|
213
|
+
# MVP: Not yet implemented
|
|
214
|
+
{recovered: false, strategy: strategy[:name], reason: "Input sanitization not yet implemented"}
|
|
215
|
+
|
|
216
|
+
when :convert_to_deterministic
|
|
217
|
+
# Converting to deterministic unit requires context about the operation
|
|
218
|
+
# MVP: Not yet implemented
|
|
219
|
+
{recovered: false, strategy: strategy[:name], reason: "Deterministic conversion not yet implemented"}
|
|
220
|
+
|
|
221
|
+
when :request_trust
|
|
222
|
+
# Trust elevation requires human intervention
|
|
223
|
+
{recovered: false, strategy: strategy[:name], reason: "Trust elevation requires manual approval"}
|
|
224
|
+
|
|
225
|
+
else
|
|
226
|
+
{recovered: false, strategy: nil, reason: "Unknown strategy"}
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Add a comment and label to the issue/PR indicating manual intervention is needed
|
|
231
|
+
def add_security_comment_and_label(violation, context, number)
|
|
232
|
+
is_pr = context.key?(:pr_number)
|
|
233
|
+
|
|
234
|
+
comment_body = build_security_comment(violation, context)
|
|
235
|
+
|
|
236
|
+
begin
|
|
237
|
+
# Add comment
|
|
238
|
+
if is_pr
|
|
239
|
+
@repository_client.add_pr_comment(number, comment_body)
|
|
240
|
+
else
|
|
241
|
+
@repository_client.add_issue_comment(number, comment_body)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Add label
|
|
245
|
+
@repository_client.add_labels(number, [needs_input_label])
|
|
246
|
+
|
|
247
|
+
Aidp.log_info("security.watch_handler", "needs_input_posted",
|
|
248
|
+
number: number,
|
|
249
|
+
is_pr: is_pr,
|
|
250
|
+
label: needs_input_label)
|
|
251
|
+
rescue => e
|
|
252
|
+
Aidp.log_error("security.watch_handler", "failed_to_post_needs_input",
|
|
253
|
+
number: number,
|
|
254
|
+
error: e.message)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Build the comment explaining the security violation
|
|
259
|
+
def build_security_comment(violation, context)
|
|
260
|
+
<<~COMMENT
|
|
261
|
+
## 🛡️ Security Policy Violation - Manual Intervention Required
|
|
262
|
+
|
|
263
|
+
AIDP encountered a **Rule of Two security violation** while processing this #{context.key?(:pr_number) ? "pull request" : "issue"}.
|
|
264
|
+
|
|
265
|
+
### What happened
|
|
266
|
+
|
|
267
|
+
The requested operation would have enabled the "lethal trifecta" - a combination of:
|
|
268
|
+
- **Untrusted input** - Processing content from external sources
|
|
269
|
+
- **Private data access** - Access to secrets or credentials
|
|
270
|
+
- **Egress capability** - Ability to communicate externally
|
|
271
|
+
|
|
272
|
+
Allowing all three simultaneously creates a security risk where a compromised prompt could exfiltrate sensitive data.
|
|
273
|
+
|
|
274
|
+
### Violation Details
|
|
275
|
+
|
|
276
|
+
- **Blocked flag**: `#{violation.flag}`
|
|
277
|
+
- **Source**: #{violation.source || "unknown"}
|
|
278
|
+
- **Work unit**: #{context[:work_unit_id] || "unknown"}
|
|
279
|
+
|
|
280
|
+
### Current state
|
|
281
|
+
#{format_current_state(violation.current_state)}
|
|
282
|
+
|
|
283
|
+
### What you can do
|
|
284
|
+
|
|
285
|
+
1. **Use the Secrets Proxy** - Register secrets and use proxy tokens instead of direct credential access
|
|
286
|
+
2. **Sanitize input** - Mark the input source as trusted (if appropriate)
|
|
287
|
+
3. **Defer egress** - Queue the external communication for manual execution
|
|
288
|
+
4. **Review and retry** - Modify the request to avoid the security conflict
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
*This comment was added by AIDP security enforcement. Remove the `#{needs_input_label}` label after resolving.*
|
|
292
|
+
COMMENT
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def format_current_state(state)
|
|
296
|
+
return "No state available" unless state.is_a?(Hash)
|
|
297
|
+
|
|
298
|
+
lines = []
|
|
299
|
+
lines << "- `untrusted_input`: #{state[:untrusted_input] ? "✓ enabled (#{state[:untrusted_input_source]})" : "✗ disabled"}"
|
|
300
|
+
lines << "- `private_data`: #{state[:private_data] ? "✓ enabled (#{state[:private_data_source]})" : "✗ disabled"}"
|
|
301
|
+
lines << "- `egress`: #{state[:egress] ? "✓ enabled (#{state[:egress_source]})" : "✗ disabled"}"
|
|
302
|
+
lines.join("\n")
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aidp
|
|
4
|
+
module Security
|
|
5
|
+
# Adapts the Rule of Two security framework to the WorkLoopRunner
|
|
6
|
+
# Tracks trifecta state per work unit and enforces policy before agent calls
|
|
7
|
+
#
|
|
8
|
+
# Integration points:
|
|
9
|
+
# - Work unit start/end lifecycle
|
|
10
|
+
# - Untrusted input detection (issues, PRs, external data)
|
|
11
|
+
# - Egress detection (git operations, API calls)
|
|
12
|
+
# - Private data detection (registered secrets)
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# adapter = WorkLoopAdapter.new(project_dir: Dir.pwd)
|
|
16
|
+
# adapter.begin_work_unit(work_unit_id: "unit_123", context: context)
|
|
17
|
+
# adapter.check_agent_call_allowed!(operation: :git_push)
|
|
18
|
+
# adapter.end_work_unit
|
|
19
|
+
class WorkLoopAdapter
|
|
20
|
+
attr_reader :project_dir, :config, :current_work_unit_id, :current_state
|
|
21
|
+
|
|
22
|
+
# Sources of untrusted input that trigger the untrusted_input flag
|
|
23
|
+
UNTRUSTED_SOURCES = %w[
|
|
24
|
+
github_issue
|
|
25
|
+
github_pr
|
|
26
|
+
github_comment
|
|
27
|
+
external_url
|
|
28
|
+
user_provided_url
|
|
29
|
+
webhook_payload
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
# Operations that constitute egress (external communication)
|
|
33
|
+
EGRESS_OPERATIONS = %w[
|
|
34
|
+
git_push
|
|
35
|
+
git_fetch
|
|
36
|
+
api_call
|
|
37
|
+
http_request
|
|
38
|
+
webhook_send
|
|
39
|
+
email_send
|
|
40
|
+
file_upload
|
|
41
|
+
pr_comment
|
|
42
|
+
issue_comment
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
def initialize(project_dir:, config: nil, enforcer: nil, secrets_proxy: nil)
|
|
46
|
+
@project_dir = project_dir
|
|
47
|
+
@config = config || load_security_config
|
|
48
|
+
@enforcer = enforcer || Aidp::Security.enforcer
|
|
49
|
+
@secrets_proxy = secrets_proxy || Aidp::Security.secrets_proxy
|
|
50
|
+
@current_work_unit_id = nil
|
|
51
|
+
@current_state = nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Check if security enforcement is enabled
|
|
55
|
+
def enabled?
|
|
56
|
+
rule_of_two_config = @config[:rule_of_two] || {}
|
|
57
|
+
rule_of_two_config.fetch(:enabled, true)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Begin tracking a work unit
|
|
61
|
+
# @param work_unit_id [String] Unique identifier for the work unit
|
|
62
|
+
# @param context [Hash] Work context containing source information
|
|
63
|
+
# @return [TrifectaState] The state object for this work unit
|
|
64
|
+
def begin_work_unit(work_unit_id:, context: {})
|
|
65
|
+
return nil unless enabled?
|
|
66
|
+
|
|
67
|
+
@current_work_unit_id = work_unit_id
|
|
68
|
+
@current_state = @enforcer.begin_work_unit(work_unit_id: work_unit_id)
|
|
69
|
+
|
|
70
|
+
# Analyze context for untrusted input
|
|
71
|
+
detect_and_enable_untrusted_input(context)
|
|
72
|
+
|
|
73
|
+
Aidp.log_debug("security.adapter", "work_unit_started",
|
|
74
|
+
work_unit_id: work_unit_id,
|
|
75
|
+
initial_state: @current_state.to_h)
|
|
76
|
+
|
|
77
|
+
@current_state
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# End tracking for current work unit
|
|
81
|
+
# @return [Hash] Final state summary
|
|
82
|
+
def end_work_unit
|
|
83
|
+
return nil unless enabled? && @current_work_unit_id
|
|
84
|
+
|
|
85
|
+
summary = @enforcer.end_work_unit(@current_work_unit_id)
|
|
86
|
+
@current_work_unit_id = nil
|
|
87
|
+
@current_state = nil
|
|
88
|
+
|
|
89
|
+
Aidp.log_debug("security.adapter", "work_unit_ended",
|
|
90
|
+
summary: summary)
|
|
91
|
+
|
|
92
|
+
summary
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Check if an agent call would be allowed and enable egress flag
|
|
96
|
+
# @param operation [String, Symbol] The type of operation (e.g., :git_push)
|
|
97
|
+
# @param requires_credentials [Boolean] Whether operation needs credentials
|
|
98
|
+
# @raise [PolicyViolation] if operation would violate Rule of Two
|
|
99
|
+
# @return [TrifectaState] The current state after enabling egress
|
|
100
|
+
def check_agent_call_allowed!(operation:, requires_credentials: false)
|
|
101
|
+
return @current_state unless enabled? && @current_state
|
|
102
|
+
|
|
103
|
+
operation_str = operation.to_s
|
|
104
|
+
|
|
105
|
+
# Check if this operation constitutes egress
|
|
106
|
+
if egress_operation?(operation_str)
|
|
107
|
+
begin
|
|
108
|
+
@current_state.enable(:egress, source: "agent_operation:#{operation_str}")
|
|
109
|
+
rescue PolicyViolation => e
|
|
110
|
+
Aidp.log_warn("security.adapter", "egress_blocked",
|
|
111
|
+
operation: operation_str,
|
|
112
|
+
reason: e.message,
|
|
113
|
+
current_state: @current_state.to_h)
|
|
114
|
+
raise
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# If operation requires credentials, check if we can enable private_data
|
|
119
|
+
if requires_credentials
|
|
120
|
+
begin
|
|
121
|
+
@current_state.enable(:private_data, source: "credential_access:#{operation_str}")
|
|
122
|
+
rescue PolicyViolation => e
|
|
123
|
+
Aidp.log_warn("security.adapter", "credential_access_blocked",
|
|
124
|
+
operation: operation_str,
|
|
125
|
+
reason: e.message,
|
|
126
|
+
current_state: @current_state.to_h)
|
|
127
|
+
raise
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
@current_state
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Request credentials through the secrets proxy
|
|
135
|
+
# This enables the private_data flag and returns a short-lived token
|
|
136
|
+
# @param secret_name [String] The registered secret name
|
|
137
|
+
# @param scope [String] The intended use of this credential
|
|
138
|
+
# @return [Hash] Token details from the secrets proxy
|
|
139
|
+
# @raise [PolicyViolation] if credential access would violate Rule of Two
|
|
140
|
+
def request_credential(secret_name:, scope: nil)
|
|
141
|
+
unless enabled?
|
|
142
|
+
# If security is disabled, return direct access (legacy mode)
|
|
143
|
+
env_var = @secrets_proxy.registry.env_var_for(secret_name)
|
|
144
|
+
return {token: ENV[env_var], direct_access: true} if env_var
|
|
145
|
+
|
|
146
|
+
raise UnregisteredSecretError.new(secret_name: secret_name)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Check if enabling private_data would violate Rule of Two
|
|
150
|
+
if @current_state&.would_create_trifecta?(:private_data)
|
|
151
|
+
raise PolicyViolation.new(
|
|
152
|
+
flag: :private_data,
|
|
153
|
+
source: "credential_request:#{secret_name}",
|
|
154
|
+
current_state: @current_state.to_h,
|
|
155
|
+
message: "Cannot access credentials for '#{secret_name}' - would create lethal trifecta"
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Enable private_data flag
|
|
160
|
+
@current_state&.enable(:private_data, source: "secrets_proxy:#{secret_name}")
|
|
161
|
+
|
|
162
|
+
# Request token from proxy
|
|
163
|
+
@secrets_proxy.request_token(secret_name: secret_name, scope: scope)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Get a sanitized environment for agent execution
|
|
167
|
+
# Strips all registered secrets from the environment
|
|
168
|
+
# @return [Hash] Sanitized environment hash
|
|
169
|
+
def sanitized_environment
|
|
170
|
+
@secrets_proxy.sanitized_environment
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Execute a block with sanitized environment
|
|
174
|
+
# @yield The block to execute
|
|
175
|
+
# @return [Object] The result of the block
|
|
176
|
+
def with_sanitized_environment(&block)
|
|
177
|
+
@secrets_proxy.with_sanitized_environment(&block)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Check if current state would allow enabling a flag
|
|
181
|
+
# @param flag [Symbol] :untrusted_input, :private_data, or :egress
|
|
182
|
+
# @return [Hash] { allowed: boolean, reason: string }
|
|
183
|
+
def would_allow?(flag)
|
|
184
|
+
return {allowed: true, reason: "Security disabled"} unless enabled?
|
|
185
|
+
return {allowed: true, reason: "No active work unit"} unless @current_state
|
|
186
|
+
|
|
187
|
+
if @current_state.would_create_trifecta?(flag)
|
|
188
|
+
{
|
|
189
|
+
allowed: false,
|
|
190
|
+
reason: "Would create lethal trifecta",
|
|
191
|
+
current_state: @current_state.to_h
|
|
192
|
+
}
|
|
193
|
+
else
|
|
194
|
+
{
|
|
195
|
+
allowed: true,
|
|
196
|
+
reason: "Operation allowed",
|
|
197
|
+
enabled_count: @current_state.enabled_count
|
|
198
|
+
}
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Get current security status for display
|
|
203
|
+
def status
|
|
204
|
+
return {enabled: false} unless enabled?
|
|
205
|
+
|
|
206
|
+
{
|
|
207
|
+
enabled: true,
|
|
208
|
+
active_work_unit: @current_work_unit_id,
|
|
209
|
+
state: @current_state&.to_h,
|
|
210
|
+
status_string: @current_state&.status_string || "No active work unit"
|
|
211
|
+
}
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
def load_security_config
|
|
217
|
+
Aidp::Config.security_config(@project_dir)
|
|
218
|
+
rescue
|
|
219
|
+
{} # Fallback to empty config
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Detect untrusted input sources in the context
|
|
223
|
+
def detect_and_enable_untrusted_input(context)
|
|
224
|
+
sources = []
|
|
225
|
+
|
|
226
|
+
# Check for GitHub issue source
|
|
227
|
+
if context[:issue_number] || context[:issue_url] || context.dig(:issue, :number)
|
|
228
|
+
sources << "github_issue"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Check for GitHub PR source
|
|
232
|
+
if context[:pr_number] || context[:pr_url] || context.dig(:pull_request, :number)
|
|
233
|
+
sources << "github_pr"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Check for external URL input
|
|
237
|
+
if context[:external_url] || context[:user_url]
|
|
238
|
+
sources << "external_url"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Check for webhook payload
|
|
242
|
+
if context[:webhook_payload] || context[:webhook_event]
|
|
243
|
+
sources << "webhook_payload"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Check for watch mode (processes untrusted issues/PRs)
|
|
247
|
+
if context[:workflow_type].to_s == "watch_mode"
|
|
248
|
+
sources << "watch_mode_untrusted_content"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Enable untrusted_input if any sources detected
|
|
252
|
+
if sources.any?
|
|
253
|
+
source_description = sources.join(", ")
|
|
254
|
+
begin
|
|
255
|
+
@current_state.enable(:untrusted_input, source: source_description)
|
|
256
|
+
Aidp.log_debug("security.adapter", "untrusted_input_detected",
|
|
257
|
+
work_unit_id: @current_work_unit_id,
|
|
258
|
+
sources: sources)
|
|
259
|
+
rescue PolicyViolation => e
|
|
260
|
+
# This shouldn't happen at the start of a work unit
|
|
261
|
+
Aidp.log_error("security.adapter", "unexpected_policy_violation",
|
|
262
|
+
error: e.message)
|
|
263
|
+
raise
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Check if operation constitutes egress
|
|
269
|
+
def egress_operation?(operation)
|
|
270
|
+
EGRESS_OPERATIONS.include?(operation) ||
|
|
271
|
+
operation.start_with?("git_") ||
|
|
272
|
+
operation.start_with?("api_") ||
|
|
273
|
+
operation.start_with?("http_")
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Security module for AIDP - Implements "Rule of Two" security framework
|
|
4
|
+
# Based on Meta's agentic security principles to prevent prompt injection attacks
|
|
5
|
+
#
|
|
6
|
+
# Core concept: Never enable more than two of three dangerous conditions:
|
|
7
|
+
# 1. untrusted_input - Processing untrusted content (issues, PRs, external data)
|
|
8
|
+
# 2. private_data - Access to secrets, credentials, or sensitive data
|
|
9
|
+
# 3. egress - Ability to communicate externally (git push, API calls, etc.)
|
|
10
|
+
#
|
|
11
|
+
# When all three would be active ("lethal trifecta"), the operation is denied.
|
|
12
|
+
|
|
13
|
+
# Error classes are defined in lib/aidp/errors.rb and aliased into this module
|
|
14
|
+
require_relative "errors"
|
|
15
|
+
require_relative "security/trifecta_state"
|
|
16
|
+
require_relative "security/rule_of_two_enforcer"
|
|
17
|
+
require_relative "security/secrets_registry"
|
|
18
|
+
require_relative "security/secrets_proxy"
|
|
19
|
+
require_relative "security/work_loop_adapter"
|
|
20
|
+
require_relative "security/watch_mode_handler"
|
|
21
|
+
|
|
22
|
+
module Aidp
|
|
23
|
+
module Security
|
|
24
|
+
class << self
|
|
25
|
+
# Get the global enforcer instance
|
|
26
|
+
def enforcer
|
|
27
|
+
@enforcer ||= RuleOfTwoEnforcer.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Get the global secrets registry
|
|
31
|
+
def secrets_registry
|
|
32
|
+
@secrets_registry ||= SecretsRegistry.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Get the global secrets proxy
|
|
36
|
+
def secrets_proxy
|
|
37
|
+
@secrets_proxy ||= SecretsProxy.new(registry: secrets_registry)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Reset all security state (primarily for testing)
|
|
41
|
+
def reset!
|
|
42
|
+
@enforcer = nil
|
|
43
|
+
@secrets_registry = nil
|
|
44
|
+
@secrets_proxy = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Check if security features are enabled
|
|
48
|
+
def enabled?(project_dir = Dir.pwd)
|
|
49
|
+
config = Aidp::Config.load(project_dir)
|
|
50
|
+
security_config = config[:security] || config["security"] || {}
|
|
51
|
+
rule_of_two_config = security_config[:rule_of_two] || security_config["rule_of_two"] || {}
|
|
52
|
+
rule_of_two_config[:enabled] != false # Default to enabled
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/aidp/setup/wizard.rb
CHANGED
|
@@ -23,6 +23,8 @@ module Aidp
|
|
|
23
23
|
}.freeze
|
|
24
24
|
|
|
25
25
|
attr_reader :project_dir, :prompt, :dry_run
|
|
26
|
+
# Expose state for testability
|
|
27
|
+
attr_reader :warnings, :existing_config, :config, :discovery_threads
|
|
26
28
|
|
|
27
29
|
def initialize(project_dir = Dir.pwd, prompt: nil, dry_run: false)
|
|
28
30
|
@project_dir = project_dir
|
|
@@ -105,8 +107,8 @@ module Aidp
|
|
|
105
107
|
providers_dir = File.join(__dir__, "../providers")
|
|
106
108
|
provider_files = Dir.glob("*.rb", base: providers_dir)
|
|
107
109
|
|
|
108
|
-
# Exclude base classes
|
|
109
|
-
excluded_files = ["base.rb"]
|
|
110
|
+
# Exclude base classes and non-provider utility files
|
|
111
|
+
excluded_files = ["base.rb", "adapter.rb", "capability_registry.rb", "error_taxonomy.rb"]
|
|
110
112
|
provider_files -= excluded_files
|
|
111
113
|
|
|
112
114
|
providers = {}
|
data/lib/aidp/version.rb
CHANGED