aidp 0.32.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/feature_analyzer.rb +322 -320
- data/lib/aidp/analyze/tree_sitter_scan.rb +3 -0
- data/lib/aidp/auto_update/coordinator.rb +97 -7
- data/lib/aidp/auto_update.rb +0 -12
- data/lib/aidp/cli/devcontainer_commands.rb +0 -5
- 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 +172 -4
- data/lib/aidp/comment_consolidator.rb +78 -0
- data/lib/aidp/concurrency/exec.rb +3 -0
- data/lib/aidp/concurrency.rb +0 -3
- data/lib/aidp/config.rb +113 -1
- data/lib/aidp/config_paths.rb +91 -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 +719 -58
- 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/ai_filter_factory.rb +285 -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/config_schema.rb +97 -1
- data/lib/aidp/harness/config_validator.rb +1 -1
- data/lib/aidp/harness/configuration.rb +61 -5
- data/lib/aidp/harness/enhanced_runner.rb +14 -11
- data/lib/aidp/harness/error_handler.rb +3 -0
- data/lib/aidp/harness/filter_definition.rb +212 -0
- data/lib/aidp/harness/generated_filter_strategy.rb +197 -0
- data/lib/aidp/harness/output_filter.rb +50 -25
- data/lib/aidp/harness/output_filter_config.rb +129 -0
- data/lib/aidp/harness/provider_factory.rb +3 -0
- data/lib/aidp/harness/provider_manager.rb +96 -2
- data/lib/aidp/harness/runner.rb +5 -12
- 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/test_runner.rb +179 -41
- data/lib/aidp/harness/thinking_depth_manager.rb +44 -28
- 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 -2
- 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 +195 -0
- data/lib/aidp/logger.rb +3 -0
- data/lib/aidp/message_display.rb +31 -0
- data/lib/aidp/metadata/compiler.rb +29 -17
- data/lib/aidp/metadata/query.rb +1 -1
- data/lib/aidp/metadata/scanner.rb +8 -1
- data/lib/aidp/metadata/tool_metadata.rb +13 -13
- data/lib/aidp/metadata/validator.rb +10 -0
- data/lib/aidp/metadata.rb +16 -0
- data/lib/aidp/pr_worktree_manager.rb +20 -8
- data/lib/aidp/provider_manager.rb +4 -7
- 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 +283 -11
- data/lib/aidp/skills.rb +0 -5
- data/lib/aidp/storage/csv_storage.rb +3 -0
- data/lib/aidp/style_guide/selector.rb +360 -0
- data/lib/aidp/tooling_detector.rb +283 -16
- 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 +682 -150
- 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 +871 -22
- data/lib/aidp/watch/review_processor.rb +33 -6
- data/lib/aidp/watch/runner.rb +80 -29
- data/lib/aidp/watch/state_store.rb +233 -0
- data/lib/aidp/watch/sub_issue_creator.rb +221 -0
- data/lib/aidp/watch.rb +5 -7
- data/lib/aidp/workflows/guided_agent.rb +4 -0
- data/lib/aidp/workstream_cleanup.rb +0 -2
- data/lib/aidp/workstream_executor.rb +3 -4
- data/lib/aidp/worktree.rb +61 -12
- data/lib/aidp/worktree_branch_manager.rb +347 -101
- data/lib/aidp.rb +21 -106
- data/templates/implementation/iterative_implementation.md +46 -3
- metadata +91 -36
- data/lib/aidp/config/paths.rb +0 -131
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aidp
|
|
4
|
+
module Security
|
|
5
|
+
# Main enforcement engine for the Rule of Two security policy
|
|
6
|
+
# Tracks trifecta state per work unit and denies operations that would
|
|
7
|
+
# create the lethal trifecta (untrusted_input + private_data + egress)
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# enforcer = RuleOfTwoEnforcer.new
|
|
11
|
+
# state = enforcer.begin_work_unit(work_unit_id: "unit_123")
|
|
12
|
+
# state.enable(:untrusted_input, source: "github_issue")
|
|
13
|
+
# state.enable(:egress, source: "git_push")
|
|
14
|
+
# # This would raise PolicyViolation:
|
|
15
|
+
# # state.enable(:private_data, source: "env_var_access")
|
|
16
|
+
# enforcer.end_work_unit("unit_123")
|
|
17
|
+
class RuleOfTwoEnforcer
|
|
18
|
+
attr_reader :config
|
|
19
|
+
|
|
20
|
+
def initialize(config: {})
|
|
21
|
+
@config = config
|
|
22
|
+
@active_states = {}
|
|
23
|
+
@completed_states = []
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Begin tracking a new work unit
|
|
28
|
+
# @param work_unit_id [String] Unique identifier for the work unit
|
|
29
|
+
# @return [TrifectaState] The state object for this work unit
|
|
30
|
+
def begin_work_unit(work_unit_id:)
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
if @active_states.key?(work_unit_id)
|
|
33
|
+
Aidp.log_warn("security.enforcer", "work_unit_already_active",
|
|
34
|
+
work_unit_id: work_unit_id)
|
|
35
|
+
return @active_states[work_unit_id]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
state = TrifectaState.new(work_unit_id: work_unit_id)
|
|
39
|
+
@active_states[work_unit_id] = state
|
|
40
|
+
|
|
41
|
+
Aidp.log_debug("security.enforcer", "work_unit_started",
|
|
42
|
+
work_unit_id: work_unit_id,
|
|
43
|
+
active_count: @active_states.size)
|
|
44
|
+
|
|
45
|
+
state
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# End tracking for a work unit
|
|
50
|
+
# @param work_unit_id [String] Unique identifier for the work unit
|
|
51
|
+
# @return [Hash] Final state summary
|
|
52
|
+
def end_work_unit(work_unit_id)
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
state = @active_states.delete(work_unit_id)
|
|
55
|
+
|
|
56
|
+
unless state
|
|
57
|
+
Aidp.log_warn("security.enforcer", "work_unit_not_found",
|
|
58
|
+
work_unit_id: work_unit_id)
|
|
59
|
+
return nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
summary = state.to_h
|
|
63
|
+
@completed_states << summary
|
|
64
|
+
|
|
65
|
+
# Keep only last 100 completed states
|
|
66
|
+
@completed_states.shift if @completed_states.size > 100
|
|
67
|
+
|
|
68
|
+
Aidp.log_debug("security.enforcer", "work_unit_ended",
|
|
69
|
+
work_unit_id: work_unit_id,
|
|
70
|
+
final_state: summary,
|
|
71
|
+
active_count: @active_states.size)
|
|
72
|
+
|
|
73
|
+
summary
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Get the state for an active work unit
|
|
78
|
+
# @param work_unit_id [String] Unique identifier for the work unit
|
|
79
|
+
# @return [TrifectaState, nil] The state object or nil if not found
|
|
80
|
+
def state_for(work_unit_id)
|
|
81
|
+
@mutex.synchronize { @active_states[work_unit_id] }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Check if a work unit is currently active
|
|
85
|
+
def active?(work_unit_id)
|
|
86
|
+
@mutex.synchronize { @active_states.key?(work_unit_id) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Get count of active work units
|
|
90
|
+
def active_count
|
|
91
|
+
@mutex.synchronize { @active_states.size }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Check if an operation would be allowed for a work unit
|
|
95
|
+
# @param work_unit_id [String] Work unit identifier
|
|
96
|
+
# @param flag [Symbol] The flag to check (:untrusted_input, :private_data, :egress)
|
|
97
|
+
# @return [Hash] { allowed: boolean, reason: string }
|
|
98
|
+
def would_allow?(work_unit_id, flag)
|
|
99
|
+
state = state_for(work_unit_id)
|
|
100
|
+
|
|
101
|
+
unless state
|
|
102
|
+
return {
|
|
103
|
+
allowed: true,
|
|
104
|
+
reason: "No active work unit - enforcement not applicable"
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if state.would_create_trifecta?(flag)
|
|
109
|
+
{
|
|
110
|
+
allowed: false,
|
|
111
|
+
reason: "Would create lethal trifecta",
|
|
112
|
+
current_state: state.to_h,
|
|
113
|
+
flag: flag
|
|
114
|
+
}
|
|
115
|
+
else
|
|
116
|
+
{
|
|
117
|
+
allowed: true,
|
|
118
|
+
reason: "Operation allowed",
|
|
119
|
+
current_state: state.to_h,
|
|
120
|
+
flag: flag
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Enforce a flag on a work unit - raises PolicyViolation on failure
|
|
126
|
+
# @param work_unit_id [String] Work unit identifier
|
|
127
|
+
# @param flag [Symbol] The flag to enable
|
|
128
|
+
# @param source [String] Description of the operation causing the flag
|
|
129
|
+
# @raise [PolicyViolation] if enabling would create lethal trifecta
|
|
130
|
+
def enforce!(work_unit_id:, flag:, source:)
|
|
131
|
+
state = state_for(work_unit_id)
|
|
132
|
+
|
|
133
|
+
unless state
|
|
134
|
+
Aidp.log_warn("security.enforcer", "enforce_on_inactive_unit",
|
|
135
|
+
work_unit_id: work_unit_id,
|
|
136
|
+
flag: flag,
|
|
137
|
+
source: source)
|
|
138
|
+
return nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
state.enable(flag, source: source)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Check if enforcement is currently enabled
|
|
145
|
+
def enabled?
|
|
146
|
+
# Default to enabled unless explicitly disabled
|
|
147
|
+
@config.fetch(:enabled, true)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Get summary of current enforcement status
|
|
151
|
+
def status_summary
|
|
152
|
+
@mutex.synchronize do
|
|
153
|
+
{
|
|
154
|
+
enabled: enabled?,
|
|
155
|
+
active_work_units: @active_states.size,
|
|
156
|
+
completed_work_units: @completed_states.size,
|
|
157
|
+
active_states: @active_states.transform_values(&:to_h),
|
|
158
|
+
recent_completions: @completed_states.last(5)
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Audit log of all completed work units with their final states
|
|
164
|
+
def audit_log
|
|
165
|
+
@mutex.synchronize { @completed_states.dup }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Reset enforcer state (primarily for testing)
|
|
169
|
+
def reset!
|
|
170
|
+
@mutex.synchronize do
|
|
171
|
+
@active_states.clear
|
|
172
|
+
@completed_states.clear
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Create a scoped execution context that automatically manages work unit lifecycle
|
|
177
|
+
# @param work_unit_id [String] Unique identifier for the work unit
|
|
178
|
+
# @yield [TrifectaState] The state object for this work unit
|
|
179
|
+
# @return [Object] The result of the block
|
|
180
|
+
def with_work_unit(work_unit_id:)
|
|
181
|
+
state = begin_work_unit(work_unit_id: work_unit_id)
|
|
182
|
+
begin
|
|
183
|
+
yield state
|
|
184
|
+
ensure
|
|
185
|
+
end_work_unit(work_unit_id)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Convenience method to wrap an agent operation with security enforcement
|
|
190
|
+
# @param work_unit_id [String] Work unit identifier
|
|
191
|
+
# @param untrusted_input_source [String, nil] Source of untrusted input
|
|
192
|
+
# @param private_data_source [String, nil] Source of private data access
|
|
193
|
+
# @param egress_source [String, nil] Source of egress capability
|
|
194
|
+
# @yield The operation to execute
|
|
195
|
+
# @return [Object] The result of the block
|
|
196
|
+
# @raise [PolicyViolation] if the combination would violate Rule of Two
|
|
197
|
+
def wrap_agent_operation(work_unit_id:, untrusted_input_source: nil,
|
|
198
|
+
private_data_source: nil, egress_source: nil)
|
|
199
|
+
with_work_unit(work_unit_id: work_unit_id) do |state|
|
|
200
|
+
# Enable flags based on what's provided
|
|
201
|
+
state.enable(:untrusted_input, source: untrusted_input_source) if untrusted_input_source
|
|
202
|
+
state.enable(:private_data, source: private_data_source) if private_data_source
|
|
203
|
+
state.enable(:egress, source: egress_source) if egress_source
|
|
204
|
+
|
|
205
|
+
yield state
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Aidp
|
|
7
|
+
module Security
|
|
8
|
+
# Broker for credential access - agents never receive raw secrets
|
|
9
|
+
# Instead, the proxy issues short-lived, capability-scoped tokens
|
|
10
|
+
# that are exchanged for actual credentials at execution time.
|
|
11
|
+
#
|
|
12
|
+
# Flow:
|
|
13
|
+
# 1. User registers secret: aidp security register-secret GITHUB_TOKEN
|
|
14
|
+
# 2. Agent requests credential access via proxy
|
|
15
|
+
# 3. Proxy issues short-lived token (e.g., 5 minutes)
|
|
16
|
+
# 4. At execution time, token is exchanged for actual credential
|
|
17
|
+
# 5. Actual credential is used only in isolated execution context
|
|
18
|
+
#
|
|
19
|
+
# This design ensures:
|
|
20
|
+
# - Agents never see raw credentials
|
|
21
|
+
# - Credential access is auditable
|
|
22
|
+
# - Tokens are scoped and time-limited
|
|
23
|
+
# - Compromised agent output can't leak credentials
|
|
24
|
+
class SecretsProxy
|
|
25
|
+
DEFAULT_TOKEN_TTL = 300 # 5 minutes
|
|
26
|
+
|
|
27
|
+
attr_reader :registry, :config
|
|
28
|
+
|
|
29
|
+
# How often to run automatic cleanup (every N operations)
|
|
30
|
+
CLEANUP_INTERVAL = 10
|
|
31
|
+
|
|
32
|
+
def initialize(registry:, config: {})
|
|
33
|
+
@registry = registry
|
|
34
|
+
@config = config
|
|
35
|
+
@active_tokens = {}
|
|
36
|
+
@token_usage_log = []
|
|
37
|
+
@mutex = Mutex.new
|
|
38
|
+
@operation_count = 0
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Request a token for accessing a registered secret
|
|
42
|
+
# @param secret_name [String] The registered secret name
|
|
43
|
+
# @param scope [String] The intended use of this token (for audit)
|
|
44
|
+
# @param ttl [Integer] Token time-to-live in seconds
|
|
45
|
+
# @return [Hash] Token details { token:, expires_at:, secret_name:, scope: }
|
|
46
|
+
# @raise [UnregisteredSecretError] if secret is not registered
|
|
47
|
+
def request_token(secret_name:, scope: nil, ttl: nil)
|
|
48
|
+
@mutex.synchronize do
|
|
49
|
+
# Periodic cleanup to prevent memory leaks
|
|
50
|
+
maybe_cleanup_expired
|
|
51
|
+
# Verify secret is registered
|
|
52
|
+
registration = @registry.get(secret_name)
|
|
53
|
+
unless registration
|
|
54
|
+
raise UnregisteredSecretError.new(secret_name: secret_name)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Check scope is allowed if scopes are defined
|
|
58
|
+
allowed_scopes = registration[:scopes] || registration["scopes"] || []
|
|
59
|
+
if allowed_scopes.any? && scope && !allowed_scopes.include?(scope)
|
|
60
|
+
raise SecretsProxyError.new(
|
|
61
|
+
secret_name: secret_name,
|
|
62
|
+
reason: "Scope '#{scope}' not allowed. Allowed scopes: #{allowed_scopes.join(", ")}"
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Generate token
|
|
67
|
+
token = generate_token
|
|
68
|
+
token_ttl = ttl || @config.fetch(:token_ttl, DEFAULT_TOKEN_TTL)
|
|
69
|
+
expires_at = Time.now + token_ttl
|
|
70
|
+
|
|
71
|
+
token_data = {
|
|
72
|
+
token: token,
|
|
73
|
+
secret_name: secret_name,
|
|
74
|
+
scope: scope,
|
|
75
|
+
expires_at: expires_at,
|
|
76
|
+
created_at: Time.now,
|
|
77
|
+
env_var: registration[:env_var] || registration["env_var"],
|
|
78
|
+
used: false
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@active_tokens[token] = token_data
|
|
82
|
+
|
|
83
|
+
Aidp.log_debug("security.proxy", "token_issued",
|
|
84
|
+
secret_name: secret_name,
|
|
85
|
+
scope: scope,
|
|
86
|
+
expires_in: token_ttl,
|
|
87
|
+
token_prefix: token[0..7])
|
|
88
|
+
|
|
89
|
+
{
|
|
90
|
+
token: token,
|
|
91
|
+
expires_at: expires_at.iso8601,
|
|
92
|
+
secret_name: secret_name,
|
|
93
|
+
scope: scope,
|
|
94
|
+
ttl: token_ttl
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Exchange a token for the actual credential value
|
|
100
|
+
# This should only be called in the isolated execution context
|
|
101
|
+
# @param token [String] The proxy token
|
|
102
|
+
# @return [String] The actual credential value
|
|
103
|
+
# @raise [TokenExpiredError] if token has expired
|
|
104
|
+
# @raise [SecretsProxyError] if token is invalid
|
|
105
|
+
def exchange_token(token)
|
|
106
|
+
@mutex.synchronize do
|
|
107
|
+
token_data = @active_tokens[token]
|
|
108
|
+
|
|
109
|
+
unless token_data
|
|
110
|
+
Aidp.log_warn("security.proxy", "invalid_token_exchange",
|
|
111
|
+
token_prefix: token[0..7] || "nil")
|
|
112
|
+
raise SecretsProxyError.new(
|
|
113
|
+
secret_name: "unknown",
|
|
114
|
+
reason: "Invalid or unknown token"
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
if Time.now > token_data[:expires_at]
|
|
119
|
+
@active_tokens.delete(token)
|
|
120
|
+
raise TokenExpiredError.new(
|
|
121
|
+
secret_name: token_data[:secret_name],
|
|
122
|
+
expired_at: token_data[:expires_at].iso8601
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Get actual value from environment
|
|
127
|
+
env_var = token_data[:env_var]
|
|
128
|
+
value = ENV[env_var]
|
|
129
|
+
|
|
130
|
+
unless value
|
|
131
|
+
raise SecretsProxyError.new(
|
|
132
|
+
secret_name: token_data[:secret_name],
|
|
133
|
+
reason: "Environment variable '#{env_var}' not set"
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Mark token as used and log
|
|
138
|
+
token_data[:used] = true
|
|
139
|
+
token_data[:used_at] = Time.now
|
|
140
|
+
|
|
141
|
+
log_token_usage(token_data)
|
|
142
|
+
|
|
143
|
+
Aidp.log_debug("security.proxy", "token_exchanged",
|
|
144
|
+
secret_name: token_data[:secret_name],
|
|
145
|
+
scope: token_data[:scope],
|
|
146
|
+
token_prefix: token[0..7])
|
|
147
|
+
|
|
148
|
+
# Return the actual secret value
|
|
149
|
+
# This value should ONLY be used in isolated execution context
|
|
150
|
+
value
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Revoke a token before it expires
|
|
155
|
+
# @param token [String] The token to revoke
|
|
156
|
+
# @return [Boolean] true if revoked, false if not found
|
|
157
|
+
def revoke_token(token)
|
|
158
|
+
@mutex.synchronize do
|
|
159
|
+
if @active_tokens.delete(token)
|
|
160
|
+
Aidp.log_info("security.proxy", "token_revoked",
|
|
161
|
+
token_prefix: token[0..7])
|
|
162
|
+
true
|
|
163
|
+
else
|
|
164
|
+
false
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Revoke all tokens for a specific secret
|
|
170
|
+
# @param secret_name [String] The secret name
|
|
171
|
+
# @return [Integer] Number of tokens revoked
|
|
172
|
+
def revoke_all_for_secret(secret_name)
|
|
173
|
+
@mutex.synchronize do
|
|
174
|
+
tokens_to_revoke = @active_tokens.select { |_t, d| d[:secret_name] == secret_name }
|
|
175
|
+
count = tokens_to_revoke.size
|
|
176
|
+
|
|
177
|
+
tokens_to_revoke.keys.each { |t| @active_tokens.delete(t) }
|
|
178
|
+
|
|
179
|
+
Aidp.log_info("security.proxy", "tokens_revoked_for_secret",
|
|
180
|
+
secret_name: secret_name,
|
|
181
|
+
count: count)
|
|
182
|
+
|
|
183
|
+
count
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Clean up expired tokens
|
|
188
|
+
# @return [Integer] Number of expired tokens removed
|
|
189
|
+
def cleanup_expired!
|
|
190
|
+
@mutex.synchronize do
|
|
191
|
+
now = Time.now
|
|
192
|
+
expired = @active_tokens.select { |_t, d| now > d[:expires_at] }
|
|
193
|
+
count = expired.size
|
|
194
|
+
|
|
195
|
+
expired.keys.each { |t| @active_tokens.delete(t) }
|
|
196
|
+
|
|
197
|
+
if count > 0
|
|
198
|
+
Aidp.log_debug("security.proxy", "expired_tokens_cleaned",
|
|
199
|
+
count: count)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
count
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Get list of active tokens (for status display)
|
|
207
|
+
# @return [Array<Hash>] Token summaries (never includes actual token values)
|
|
208
|
+
def active_tokens_summary
|
|
209
|
+
@mutex.synchronize do
|
|
210
|
+
@active_tokens.values.map do |data|
|
|
211
|
+
{
|
|
212
|
+
secret_name: data[:secret_name],
|
|
213
|
+
scope: data[:scope],
|
|
214
|
+
expires_at: data[:expires_at].iso8601,
|
|
215
|
+
created_at: data[:created_at].iso8601,
|
|
216
|
+
used: data[:used],
|
|
217
|
+
remaining_ttl: [(data[:expires_at] - Time.now).to_i, 0].max
|
|
218
|
+
}
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Get usage audit log
|
|
224
|
+
# @param limit [Integer] Maximum entries to return
|
|
225
|
+
# @return [Array<Hash>] Recent token usage records
|
|
226
|
+
def usage_log(limit: 50)
|
|
227
|
+
@mutex.synchronize do
|
|
228
|
+
@token_usage_log.last(limit)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Build a sanitized environment hash with registered secrets stripped
|
|
233
|
+
# @param base_env [Hash] The base environment (defaults to ENV.to_h)
|
|
234
|
+
# @return [Hash] Environment with registered secrets removed
|
|
235
|
+
def sanitized_environment(base_env = ENV.to_h)
|
|
236
|
+
env = base_env.dup
|
|
237
|
+
vars_to_strip = @registry.env_vars_to_strip
|
|
238
|
+
|
|
239
|
+
vars_to_strip.each do |var|
|
|
240
|
+
if env.key?(var)
|
|
241
|
+
env.delete(var)
|
|
242
|
+
Aidp.log_debug("security.proxy", "env_var_stripped",
|
|
243
|
+
env_var: var)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
env
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Execute a block with a sanitized environment
|
|
251
|
+
# Registered secrets are stripped from ENV during execution
|
|
252
|
+
# @yield The block to execute in sanitized environment
|
|
253
|
+
# @return [Object] The result of the block
|
|
254
|
+
def with_sanitized_environment
|
|
255
|
+
original_env = {}
|
|
256
|
+
vars_to_strip = @registry.env_vars_to_strip
|
|
257
|
+
|
|
258
|
+
# Save and clear registered secrets
|
|
259
|
+
vars_to_strip.each do |var|
|
|
260
|
+
if ENV.key?(var)
|
|
261
|
+
original_env[var] = ENV[var]
|
|
262
|
+
ENV.delete(var)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
Aidp.log_debug("security.proxy", "environment_sanitized",
|
|
267
|
+
stripped_count: original_env.size)
|
|
268
|
+
|
|
269
|
+
begin
|
|
270
|
+
yield
|
|
271
|
+
ensure
|
|
272
|
+
# Restore secrets
|
|
273
|
+
original_env.each { |k, v| ENV[k] = v }
|
|
274
|
+
|
|
275
|
+
Aidp.log_debug("security.proxy", "environment_restored",
|
|
276
|
+
restored_count: original_env.size)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Reset proxy state (primarily for testing)
|
|
281
|
+
def reset!
|
|
282
|
+
@mutex.synchronize do
|
|
283
|
+
@active_tokens.clear
|
|
284
|
+
@token_usage_log.clear
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
private
|
|
289
|
+
|
|
290
|
+
# Periodically clean up expired tokens to prevent memory leaks
|
|
291
|
+
# Called automatically during token operations
|
|
292
|
+
def maybe_cleanup_expired
|
|
293
|
+
@operation_count += 1
|
|
294
|
+
return unless @operation_count >= CLEANUP_INTERVAL
|
|
295
|
+
|
|
296
|
+
@operation_count = 0
|
|
297
|
+
cleanup_expired_internal
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Internal cleanup without mutex (called from within synchronized blocks)
|
|
301
|
+
def cleanup_expired_internal
|
|
302
|
+
now = Time.now
|
|
303
|
+
expired = @active_tokens.select { |_t, d| now > d[:expires_at] }
|
|
304
|
+
return if expired.empty?
|
|
305
|
+
|
|
306
|
+
expired.keys.each { |t| @active_tokens.delete(t) }
|
|
307
|
+
Aidp.log_debug("security.proxy", "auto_cleanup_expired", count: expired.size)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def generate_token
|
|
311
|
+
"aidp_proxy_#{SecureRandom.hex(24)}"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def log_token_usage(token_data)
|
|
315
|
+
@token_usage_log << {
|
|
316
|
+
secret_name: token_data[:secret_name],
|
|
317
|
+
scope: token_data[:scope],
|
|
318
|
+
created_at: token_data[:created_at].iso8601,
|
|
319
|
+
used_at: token_data[:used_at].iso8601,
|
|
320
|
+
ttl_remaining: (token_data[:expires_at] - token_data[:used_at]).to_i
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
# Keep log bounded
|
|
324
|
+
@token_usage_log.shift if @token_usage_log.size > 1000
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|