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
@@ -3,15 +3,27 @@ require "fileutils"
3
3
  require "shellwords"
4
4
 
5
5
  module Aidp
6
+ # Simple shell command executor wrapper for testability
7
+ class ShellExecutor
8
+ def run(command)
9
+ `#{command}`
10
+ end
11
+
12
+ def success?
13
+ $?.success?
14
+ end
15
+ end
16
+
6
17
  # Manages worktrees specifically for Pull Request branches
7
18
  class PRWorktreeManager
8
- def initialize(base_repo_path: nil, project_dir: nil, worktree_registry_path: nil)
19
+ def initialize(base_repo_path: nil, project_dir: nil, worktree_registry_path: nil, shell_executor: nil)
9
20
  @base_repo_path = base_repo_path || project_dir || Dir.pwd
10
21
  @project_dir = project_dir
11
22
  @worktree_registry_path = worktree_registry_path || File.join(
12
23
  project_dir || File.expand_path("~/.aidp"),
13
24
  "pr_worktrees.json"
14
25
  )
26
+ @shell_executor = shell_executor || ShellExecutor.new
15
27
  FileUtils.mkdir_p(File.dirname(@worktree_registry_path))
16
28
  @worktrees = load_registry
17
29
  end
@@ -427,7 +439,7 @@ module Aidp
427
439
 
428
440
  Dir.chdir(worktree_path) do
429
441
  # Check staged changes with more robust capture
430
- staged_changes_output = `git diff --staged --name-only`.strip
442
+ staged_changes_output = @shell_executor.run("git diff --staged --name-only").strip
431
443
 
432
444
  if !staged_changes_output.empty?
433
445
  push_result[:git_actions][:staged_changes] = true
@@ -436,16 +448,16 @@ module Aidp
436
448
  # More robust commit command with additional logging
437
449
  commit_message = "Changes applied via AIDP request-changes workflow for PR ##{pr_number}"
438
450
  commit_command = "git commit -m '#{commit_message}' 2>&1"
439
- commit_output = `#{commit_command}`.strip
451
+ commit_output = @shell_executor.run(commit_command).strip
440
452
 
441
- if $?.success?
453
+ if @shell_executor.success?
442
454
  push_result[:git_actions][:committed] = true
443
455
 
444
456
  # Enhanced push with verbose tracking
445
457
  push_command = "git push origin #{head_branch} 2>&1"
446
- push_output = `#{push_command}`.strip
458
+ push_output = @shell_executor.run(push_command).strip
447
459
 
448
- if $?.success?
460
+ if @shell_executor.success?
449
461
  push_result[:git_actions][:pushed] = true
450
462
  push_result[:success] = true
451
463
 
@@ -5,6 +5,9 @@ require "tty-prompt"
5
5
  module Aidp
6
6
  class ProviderManager
7
7
  class << self
8
+ # Expose for testability
9
+ attr_accessor :harness_factory
10
+
8
11
  def get_provider(provider_type, options = {})
9
12
  factory = get_harness_factory
10
13
  raise "Harness factory not available" unless factory
@@ -48,6 +48,8 @@ module Aidp
48
48
  }.freeze
49
49
 
50
50
  attr_reader :activity_state, :last_activity_time, :start_time, :step_name, :model
51
+ # Expose for testability
52
+ attr_writer :harness_context
51
53
 
52
54
  def initialize(output: nil, prompt: TTY::Prompt.new)
53
55
  @activity_state = :idle
@@ -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