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,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "securerandom"
6
+ require "time"
7
+
8
+ module Aidp
9
+ module Security
10
+ # Registry for user-declared secrets that should be proxied
11
+ # Secrets are registered by name, with the actual value stored securely
12
+ # and never exposed directly to agents.
13
+ #
14
+ # Storage: .aidp/security/secrets_registry.json
15
+ # Format: { "SECRET_NAME": { "env_var": "ACTUAL_ENV_VAR", "registered_at": timestamp } }
16
+ #
17
+ # The registry only stores metadata - actual secret values come from environment
18
+ # variables at runtime. This ensures secrets are never persisted to disk.
19
+ class SecretsRegistry
20
+ REGISTRY_FILENAME = "secrets_registry.json"
21
+
22
+ attr_reader :project_dir
23
+
24
+ def initialize(project_dir: Dir.pwd)
25
+ @project_dir = project_dir
26
+ @cache = nil
27
+ @cache_mtime = nil
28
+ @mutex = Mutex.new
29
+ end
30
+
31
+ # Register a secret by name
32
+ # @param name [String] The name to reference this secret by
33
+ # @param env_var [String] The environment variable containing the secret
34
+ # @param description [String] Optional description of what this secret is for
35
+ # @param scopes [Array<String>] Optional list of allowed operations for this secret
36
+ # @return [Hash] Registration details
37
+ def register(name:, env_var:, description: nil, scopes: [])
38
+ @mutex.synchronize do
39
+ registry = load_registry
40
+
41
+ # Validate the environment variable exists (but don't store the value)
42
+ unless ENV.key?(env_var)
43
+ Aidp.log_warn("security.registry", "env_var_not_found",
44
+ name: name,
45
+ env_var: env_var)
46
+ end
47
+
48
+ registration = {
49
+ env_var: env_var,
50
+ description: description,
51
+ scopes: scopes,
52
+ registered_at: Time.now.iso8601,
53
+ id: SecureRandom.hex(8)
54
+ }
55
+
56
+ registry[name] = registration
57
+ save_registry(registry)
58
+
59
+ Aidp.log_info("security.registry", "secret_registered",
60
+ name: name,
61
+ env_var: env_var,
62
+ scopes: scopes)
63
+
64
+ registration.merge(name: name)
65
+ end
66
+ end
67
+
68
+ # Unregister a secret
69
+ # @param name [String] The secret name to remove
70
+ # @return [Boolean] true if removed, false if not found
71
+ def unregister(name:)
72
+ @mutex.synchronize do
73
+ registry = load_registry
74
+
75
+ key = registry.key?(name.to_sym) ? name.to_sym : name.to_s
76
+ unless registry.key?(key)
77
+ Aidp.log_warn("security.registry", "secret_not_found_for_unregister",
78
+ name: name)
79
+ return false
80
+ end
81
+
82
+ registry.delete(key)
83
+ save_registry(registry)
84
+
85
+ Aidp.log_info("security.registry", "secret_unregistered", name: name)
86
+ true
87
+ end
88
+ end
89
+
90
+ # Check if a secret is registered
91
+ # @param name [String] The secret name
92
+ # @return [Boolean]
93
+ def registered?(name)
94
+ @mutex.synchronize do
95
+ registry = load_registry
96
+ registry.key?(name.to_sym) || registry.key?(name.to_s)
97
+ end
98
+ end
99
+
100
+ # Get registration details for a secret (without the actual value)
101
+ # @param name [String] The secret name
102
+ # @return [Hash, nil] Registration details or nil if not found
103
+ def get(name)
104
+ @mutex.synchronize do
105
+ registry = load_registry
106
+ entry = registry[name.to_sym] || registry[name.to_s]
107
+ return nil unless entry
108
+
109
+ entry.merge(name: name)
110
+ end
111
+ end
112
+
113
+ # Get the environment variable name for a secret
114
+ # @param name [String] The secret name
115
+ # @return [String, nil] The env var name or nil if not registered
116
+ def env_var_for(name)
117
+ entry = get(name)
118
+ entry&.dig(:env_var) || entry&.dig("env_var")
119
+ end
120
+
121
+ # List all registered secrets (names and metadata only, never values)
122
+ # @return [Array<Hash>] List of registered secrets with metadata
123
+ def list
124
+ @mutex.synchronize do
125
+ registry = load_registry
126
+ registry.map do |name, details|
127
+ {
128
+ name: name,
129
+ env_var: details[:env_var] || details["env_var"],
130
+ description: details[:description] || details["description"],
131
+ scopes: details[:scopes] || details["scopes"] || [],
132
+ registered_at: details[:registered_at] || details["registered_at"],
133
+ has_value: ENV.key?(details[:env_var] || details["env_var"])
134
+ }
135
+ end
136
+ end
137
+ end
138
+
139
+ # Get list of environment variables that should be stripped from agent environment
140
+ # @return [Array<String>] List of env var names to remove
141
+ def env_vars_to_strip
142
+ @mutex.synchronize do
143
+ registry = load_registry
144
+ registry.values.map { |entry| entry[:env_var] || entry["env_var"] }.compact.uniq
145
+ end
146
+ end
147
+
148
+ # Check if an environment variable is registered as a secret
149
+ # @param env_var [String] The environment variable name
150
+ # @return [Boolean]
151
+ def env_var_registered?(env_var)
152
+ @mutex.synchronize do
153
+ registry = load_registry
154
+ registry.values.any? { |entry| (entry[:env_var] || entry["env_var"]) == env_var }
155
+ end
156
+ end
157
+
158
+ # Get the secret name for an environment variable
159
+ # @param env_var [String] The environment variable name
160
+ # @return [String, nil] The secret name or nil if not found
161
+ def name_for_env_var(env_var)
162
+ @mutex.synchronize do
163
+ registry = load_registry
164
+ registry.find { |_name, entry| (entry[:env_var] || entry["env_var"]) == env_var }&.first
165
+ end
166
+ end
167
+
168
+ # Clear the in-memory cache (forces reload on next access)
169
+ def clear_cache!
170
+ @mutex.synchronize do
171
+ @cache = nil
172
+ @cache_mtime = nil
173
+ end
174
+ end
175
+
176
+ private
177
+
178
+ def registry_path
179
+ File.join(@project_dir, ".aidp", "security", REGISTRY_FILENAME)
180
+ end
181
+
182
+ def ensure_security_dir
183
+ dir = File.dirname(registry_path)
184
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
185
+ end
186
+
187
+ def load_registry
188
+ # Check if cache is still valid based on file mtime
189
+ return @cache if cache_valid?
190
+
191
+ if File.exist?(registry_path)
192
+ content = File.read(registry_path)
193
+ @cache = JSON.parse(content, symbolize_names: true)
194
+ @cache_mtime = File.mtime(registry_path)
195
+ Aidp.log_debug("security.registry", "cache_loaded", path: registry_path)
196
+ else
197
+ @cache = {}
198
+ @cache_mtime = nil
199
+ end
200
+
201
+ @cache
202
+ rescue JSON::ParserError => e
203
+ Aidp.log_error("security.registry", "failed_to_parse_registry",
204
+ error: e.message,
205
+ path: registry_path)
206
+ @cache = {}
207
+ @cache_mtime = nil
208
+ @cache
209
+ end
210
+
211
+ def cache_valid?
212
+ return false unless @cache
213
+ return true unless File.exist?(registry_path)
214
+
215
+ current_mtime = File.mtime(registry_path)
216
+ @cache_mtime && current_mtime == @cache_mtime
217
+ end
218
+
219
+ def save_registry(registry)
220
+ ensure_security_dir
221
+ File.write(registry_path, JSON.pretty_generate(registry))
222
+ @cache = registry
223
+ @cache_mtime = File.mtime(registry_path)
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aidp
4
+ module Security
5
+ # Tracks the three security flags that form the "lethal trifecta"
6
+ # Per Rule of Two: never enable all three simultaneously
7
+ #
8
+ # Flags:
9
+ # - untrusted_input: Processing content from untrusted sources (issues, PRs, external data)
10
+ # - private_data: Access to secrets, credentials, or sensitive data
11
+ # - egress: Ability to communicate externally (git push, API calls, network access)
12
+ #
13
+ # State is tracked per work unit and resets between units.
14
+ class TrifectaState
15
+ attr_reader :untrusted_input, :private_data, :egress, :work_unit_id
16
+
17
+ # Track sources for audit logging
18
+ attr_reader :untrusted_input_source, :private_data_source, :egress_source
19
+
20
+ def initialize(work_unit_id: nil)
21
+ @work_unit_id = work_unit_id || SecureRandom.hex(8)
22
+ @untrusted_input = false
23
+ @private_data = false
24
+ @egress = false
25
+ @untrusted_input_source = nil
26
+ @private_data_source = nil
27
+ @egress_source = nil
28
+ @frozen = false
29
+ end
30
+
31
+ # Check if enabling a flag would create the lethal trifecta
32
+ # @param flag [Symbol] :untrusted_input, :private_data, or :egress
33
+ # @return [Boolean] true if enabling would create lethal trifecta
34
+ def would_create_trifecta?(flag)
35
+ case flag
36
+ when :untrusted_input
37
+ private_data && egress
38
+ when :private_data
39
+ untrusted_input && egress
40
+ when :egress
41
+ untrusted_input && private_data
42
+ else
43
+ raise ArgumentError, "Unknown trifecta flag: #{flag}"
44
+ end
45
+ end
46
+
47
+ # Check if the lethal trifecta is currently active
48
+ # @return [Boolean] true if all three flags are enabled
49
+ def lethal_trifecta?
50
+ untrusted_input && private_data && egress
51
+ end
52
+
53
+ # Count of currently enabled flags
54
+ # @return [Integer] 0, 1, 2, or 3
55
+ def enabled_count
56
+ [untrusted_input, private_data, egress].count(true)
57
+ end
58
+
59
+ # Enable a flag with source tracking
60
+ # @param flag [Symbol] :untrusted_input, :private_data, or :egress
61
+ # @param source [String] Description of what caused this flag to be enabled
62
+ # @raise [PolicyViolation] if enabling would create lethal trifecta
63
+ # @return [TrifectaState] self for chaining
64
+ def enable(flag, source: nil)
65
+ raise FrozenStateError, "Cannot modify frozen trifecta state" if @frozen
66
+
67
+ if would_create_trifecta?(flag)
68
+ raise PolicyViolation.new(
69
+ flag: flag,
70
+ source: source,
71
+ current_state: to_h,
72
+ message: build_violation_message(flag, source)
73
+ )
74
+ end
75
+
76
+ case flag
77
+ when :untrusted_input
78
+ @untrusted_input = true
79
+ @untrusted_input_source = source
80
+ when :private_data
81
+ @private_data = true
82
+ @private_data_source = source
83
+ when :egress
84
+ @egress = true
85
+ @egress_source = source
86
+ else
87
+ raise ArgumentError, "Unknown trifecta flag: #{flag}"
88
+ end
89
+
90
+ log_flag_enabled(flag, source)
91
+ self
92
+ end
93
+
94
+ # Disable a flag
95
+ # @param flag [Symbol] :untrusted_input, :private_data, or :egress
96
+ # @return [TrifectaState] self for chaining
97
+ def disable(flag)
98
+ raise FrozenStateError, "Cannot modify frozen trifecta state" if @frozen
99
+
100
+ case flag
101
+ when :untrusted_input
102
+ @untrusted_input = false
103
+ @untrusted_input_source = nil
104
+ when :private_data
105
+ @private_data = false
106
+ @private_data_source = nil
107
+ when :egress
108
+ @egress = false
109
+ @egress_source = nil
110
+ else
111
+ raise ArgumentError, "Unknown trifecta flag: #{flag}"
112
+ end
113
+
114
+ log_flag_disabled(flag)
115
+ self
116
+ end
117
+
118
+ # Freeze state - no further modifications allowed
119
+ # Used when passing state to execution context
120
+ def freeze!
121
+ @frozen = true
122
+ self
123
+ end
124
+
125
+ # Check if state is frozen
126
+ def frozen?
127
+ @frozen
128
+ end
129
+
130
+ # Create a copy of this state (unfrozen)
131
+ def dup
132
+ new_state = TrifectaState.new(work_unit_id: "#{@work_unit_id}_dup")
133
+ new_state.instance_variable_set(:@untrusted_input, @untrusted_input)
134
+ new_state.instance_variable_set(:@private_data, @private_data)
135
+ new_state.instance_variable_set(:@egress, @egress)
136
+ new_state.instance_variable_set(:@untrusted_input_source, @untrusted_input_source)
137
+ new_state.instance_variable_set(:@private_data_source, @private_data_source)
138
+ new_state.instance_variable_set(:@egress_source, @egress_source)
139
+ new_state
140
+ end
141
+
142
+ # Export state as hash for logging/serialization
143
+ def to_h
144
+ {
145
+ work_unit_id: @work_unit_id,
146
+ untrusted_input: @untrusted_input,
147
+ untrusted_input_source: @untrusted_input_source,
148
+ private_data: @private_data,
149
+ private_data_source: @private_data_source,
150
+ egress: @egress,
151
+ egress_source: @egress_source,
152
+ enabled_count: enabled_count,
153
+ lethal_trifecta: lethal_trifecta?,
154
+ frozen: @frozen
155
+ }
156
+ end
157
+
158
+ # Human-readable status string
159
+ def status_string
160
+ flags = []
161
+ flags << "untrusted_input" if untrusted_input
162
+ flags << "private_data" if private_data
163
+ flags << "egress" if egress
164
+
165
+ if flags.empty?
166
+ "No flags enabled (safe)"
167
+ elsif lethal_trifecta?
168
+ "LETHAL TRIFECTA: #{flags.join(", ")}"
169
+ else
170
+ "Enabled: #{flags.join(", ")} (#{enabled_count}/3 - safe)"
171
+ end
172
+ end
173
+
174
+ private
175
+
176
+ def build_violation_message(flag, source)
177
+ existing_flags = []
178
+ existing_flags << "untrusted_input (#{@untrusted_input_source || "unknown source"})" if untrusted_input
179
+ existing_flags << "private_data (#{@private_data_source || "unknown source"})" if private_data
180
+ existing_flags << "egress (#{@egress_source || "unknown source"})" if egress
181
+
182
+ <<~MSG.strip
183
+ Rule of Two violation: Cannot enable '#{flag}' (#{source || "unknown source"})
184
+
185
+ Currently enabled flags:
186
+ #{existing_flags.map { |f| " - #{f}" }.join("\n")}
187
+
188
+ Enabling '#{flag}' would create the lethal trifecta where an agent has:
189
+ 1. Access to untrusted input (prompt injection vector)
190
+ 2. Access to private data/secrets
191
+ 3. Ability to exfiltrate via external communication
192
+
193
+ To proceed, you must either:
194
+ - Use the Secrets Proxy to isolate credential access
195
+ - Sanitize untrusted input before processing
196
+ - Disable external communication for this operation
197
+ MSG
198
+ end
199
+
200
+ def log_flag_enabled(flag, source)
201
+ Aidp.log_debug("security.trifecta", "flag_enabled",
202
+ work_unit_id: @work_unit_id,
203
+ flag: flag,
204
+ source: source,
205
+ enabled_count: enabled_count,
206
+ state: to_h)
207
+ end
208
+
209
+ def log_flag_disabled(flag)
210
+ Aidp.log_debug("security.trifecta", "flag_disabled",
211
+ work_unit_id: @work_unit_id,
212
+ flag: flag,
213
+ enabled_count: enabled_count)
214
+ end
215
+ end
216
+
217
+ # Error raised when attempting to modify frozen state
218
+ class FrozenStateError < StandardError; end
219
+ end
220
+ end