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.
- 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/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 +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
|