aidp 0.33.0 → 0.34.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.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +35 -0
  3. data/lib/aidp/analyze/tree_sitter_scan.rb +3 -0
  4. data/lib/aidp/cli/eval_command.rb +399 -0
  5. data/lib/aidp/cli/harness_command.rb +1 -1
  6. data/lib/aidp/cli/security_command.rb +416 -0
  7. data/lib/aidp/cli/tools_command.rb +6 -4
  8. data/lib/aidp/cli.rb +170 -3
  9. data/lib/aidp/concurrency/exec.rb +3 -0
  10. data/lib/aidp/config.rb +113 -0
  11. data/lib/aidp/config_paths.rb +20 -0
  12. data/lib/aidp/daemon/runner.rb +8 -4
  13. data/lib/aidp/errors.rb +134 -0
  14. data/lib/aidp/evaluations/context_capture.rb +205 -0
  15. data/lib/aidp/evaluations/evaluation_record.rb +114 -0
  16. data/lib/aidp/evaluations/evaluation_storage.rb +250 -0
  17. data/lib/aidp/evaluations.rb +23 -0
  18. data/lib/aidp/execute/async_work_loop_runner.rb +4 -1
  19. data/lib/aidp/execute/interactive_repl.rb +6 -2
  20. data/lib/aidp/execute/prompt_evaluator.rb +359 -0
  21. data/lib/aidp/execute/repl_macros.rb +100 -1
  22. data/lib/aidp/execute/work_loop_runner.rb +399 -47
  23. data/lib/aidp/execute/work_loop_state.rb +4 -1
  24. data/lib/aidp/execute/workflow_selector.rb +3 -0
  25. data/lib/aidp/harness/ai_decision_engine.rb +79 -0
  26. data/lib/aidp/harness/capability_registry.rb +2 -0
  27. data/lib/aidp/harness/condition_detector.rb +3 -0
  28. data/lib/aidp/harness/config_loader.rb +3 -0
  29. data/lib/aidp/harness/enhanced_runner.rb +14 -11
  30. data/lib/aidp/harness/error_handler.rb +3 -0
  31. data/lib/aidp/harness/provider_factory.rb +3 -0
  32. data/lib/aidp/harness/provider_manager.rb +6 -0
  33. data/lib/aidp/harness/runner.rb +5 -1
  34. data/lib/aidp/harness/state/persistence.rb +3 -0
  35. data/lib/aidp/harness/state_manager.rb +3 -0
  36. data/lib/aidp/harness/status_display.rb +28 -20
  37. data/lib/aidp/harness/thinking_depth_manager.rb +32 -32
  38. data/lib/aidp/harness/ui/enhanced_tui.rb +4 -0
  39. data/lib/aidp/harness/ui/enhanced_workflow_selector.rb +4 -0
  40. data/lib/aidp/harness/ui/error_handler.rb +3 -0
  41. data/lib/aidp/harness/ui/job_monitor.rb +4 -0
  42. data/lib/aidp/harness/ui/navigation/submenu.rb +2 -0
  43. data/lib/aidp/harness/ui/navigation/workflow_selector.rb +6 -0
  44. data/lib/aidp/harness/ui/spinner_helper.rb +3 -0
  45. data/lib/aidp/harness/ui/workflow_controller.rb +3 -0
  46. data/lib/aidp/harness/user_interface.rb +3 -0
  47. data/lib/aidp/loader.rb +2 -2
  48. data/lib/aidp/logger.rb +3 -0
  49. data/lib/aidp/message_display.rb +31 -0
  50. data/lib/aidp/pr_worktree_manager.rb +18 -6
  51. data/lib/aidp/provider_manager.rb +3 -0
  52. data/lib/aidp/providers/base.rb +2 -0
  53. data/lib/aidp/security/rule_of_two_enforcer.rb +210 -0
  54. data/lib/aidp/security/secrets_proxy.rb +328 -0
  55. data/lib/aidp/security/secrets_registry.rb +227 -0
  56. data/lib/aidp/security/trifecta_state.rb +220 -0
  57. data/lib/aidp/security/watch_mode_handler.rb +306 -0
  58. data/lib/aidp/security/work_loop_adapter.rb +277 -0
  59. data/lib/aidp/security.rb +56 -0
  60. data/lib/aidp/setup/wizard.rb +4 -2
  61. data/lib/aidp/version.rb +1 -1
  62. data/lib/aidp/watch/auto_merger.rb +274 -0
  63. data/lib/aidp/watch/auto_pr_processor.rb +125 -7
  64. data/lib/aidp/watch/build_processor.rb +16 -1
  65. data/lib/aidp/watch/change_request_processor.rb +680 -286
  66. data/lib/aidp/watch/ci_fix_processor.rb +262 -4
  67. data/lib/aidp/watch/feedback_collector.rb +191 -0
  68. data/lib/aidp/watch/hierarchical_pr_strategy.rb +256 -0
  69. data/lib/aidp/watch/implementation_verifier.rb +142 -1
  70. data/lib/aidp/watch/plan_generator.rb +70 -13
  71. data/lib/aidp/watch/plan_processor.rb +12 -5
  72. data/lib/aidp/watch/projects_processor.rb +286 -0
  73. data/lib/aidp/watch/repository_client.rb +861 -53
  74. data/lib/aidp/watch/review_processor.rb +33 -6
  75. data/lib/aidp/watch/runner.rb +51 -11
  76. data/lib/aidp/watch/state_store.rb +233 -0
  77. data/lib/aidp/watch/sub_issue_creator.rb +221 -0
  78. data/lib/aidp/workflows/guided_agent.rb +4 -0
  79. data/lib/aidp/workstream_executor.rb +3 -0
  80. data/lib/aidp/worktree.rb +61 -11
  81. data/lib/aidp/worktree_branch_manager.rb +347 -101
  82. data/templates/implementation/iterative_implementation.md +46 -3
  83. metadata +20 -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Aidp
4
- VERSION = "0.33.0"
4
+ VERSION = "0.34.0"
5
5
  end