Class: Aidp::Security::SecretsProxy

Inherits:
Object
  • Object
show all
Defined in:
lib/aidp/security/secrets_proxy.rb

Overview

Broker for credential access - agents never receive raw secrets Instead, the proxy issues short-lived, capability-scoped tokens that are exchanged for actual credentials at execution time.

Flow:

  1. User registers secret: aidp security register-secret GITHUB_TOKEN

  2. Agent requests credential access via proxy

  3. Proxy issues short-lived token (e.g., 5 minutes)

  4. At execution time, token is exchanged for actual credential

  5. Actual credential is used only in isolated execution context

This design ensures:

  • Agents never see raw credentials

  • Credential access is auditable

  • Tokens are scoped and time-limited

  • Compromised agent output can’t leak credentials

Constant Summary collapse

DEFAULT_TOKEN_TTL =

5 minutes

300
CLEANUP_INTERVAL =

How often to run automatic cleanup (every N operations)

10

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(registry:, config: {}) ⇒ SecretsProxy

Returns a new instance of SecretsProxy.



32
33
34
35
36
37
38
39
# File 'lib/aidp/security/secrets_proxy.rb', line 32

def initialize(registry:, config: {})
  @registry = registry
  @config = config
  @active_tokens = {}
  @token_usage_log = []
  @mutex = Mutex.new
  @operation_count = 0
end

Instance Attribute Details

#configObject (readonly)

Returns the value of attribute config.



27
28
29
# File 'lib/aidp/security/secrets_proxy.rb', line 27

def config
  @config
end

#registryObject (readonly)

Returns the value of attribute registry.



27
28
29
# File 'lib/aidp/security/secrets_proxy.rb', line 27

def registry
  @registry
end

Instance Method Details

#active_tokens_summaryArray<Hash>

Get list of active tokens (for status display)

Returns:

  • (Array<Hash>)

    Token summaries (never includes actual token values)



208
209
210
211
212
213
214
215
216
217
218
219
220
221
# File 'lib/aidp/security/secrets_proxy.rb', line 208

def active_tokens_summary
  @mutex.synchronize do
    @active_tokens.values.map do |data|
      {
        secret_name: data[:secret_name],
        scope: data[:scope],
        expires_at: data[:expires_at].iso8601,
        created_at: data[:created_at].iso8601,
        used: data[:used],
        remaining_ttl: [(data[:expires_at] - Time.now).to_i, 0].max
      }
    end
  end
end

#cleanup_expired!Integer

Clean up expired tokens

Returns:

  • (Integer)

    Number of expired tokens removed



189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/aidp/security/secrets_proxy.rb', line 189

def cleanup_expired!
  @mutex.synchronize do
    now = Time.now
    expired = @active_tokens.select { |_t, d| now > d[:expires_at] }
    count = expired.size

    expired.keys.each { |t| @active_tokens.delete(t) }

    if count > 0
      Aidp.log_debug("security.proxy", "expired_tokens_cleaned",
        count: count)
    end

    count
  end
end

#exchange_token(token) ⇒ String

Exchange a token for the actual credential value This should only be called in the isolated execution context

Parameters:

  • token (String)

    The proxy token

Returns:

  • (String)

    The actual credential value

Raises:



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/aidp/security/secrets_proxy.rb', line 105

def exchange_token(token)
  @mutex.synchronize do
    token_data = @active_tokens[token]

    unless token_data
      Aidp.log_warn("security.proxy", "invalid_token_exchange",
        token_prefix: token[0..7] || "nil")
      raise SecretsProxyError.new(
        secret_name: "unknown",
        reason: "Invalid or unknown token"
      )
    end

    if Time.now > token_data[:expires_at]
      @active_tokens.delete(token)
      raise TokenExpiredError.new(
        secret_name: token_data[:secret_name],
        expired_at: token_data[:expires_at].iso8601
      )
    end

    # Get actual value from environment
    env_var = token_data[:env_var]
    value = ENV[env_var]

    unless value
      raise SecretsProxyError.new(
        secret_name: token_data[:secret_name],
        reason: "Environment variable '#{env_var}' not set"
      )
    end

    # Mark token as used and log
    token_data[:used] = true
    token_data[:used_at] = Time.now

    log_token_usage(token_data)

    Aidp.log_debug("security.proxy", "token_exchanged",
      secret_name: token_data[:secret_name],
      scope: token_data[:scope],
      token_prefix: token[0..7])

    # Return the actual secret value
    # This value should ONLY be used in isolated execution context
    value
  end
end

#request_token(secret_name:, scope: nil, ttl: nil) ⇒ Hash

Request a token for accessing a registered secret

Parameters:

  • secret_name (String)

    The registered secret name

  • scope (String) (defaults to: nil)

    The intended use of this token (for audit)

  • ttl (Integer) (defaults to: nil)

    Token time-to-live in seconds

Returns:

  • (Hash)

    Token details { token:, expires_at:, secret_name:, scope: }

Raises:



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/aidp/security/secrets_proxy.rb', line 47

def request_token(secret_name:, scope: nil, ttl: nil)
  @mutex.synchronize do
    # Periodic cleanup to prevent memory leaks
    maybe_cleanup_expired
    # Verify secret is registered
    registration = @registry.get(secret_name)
    unless registration
      raise UnregisteredSecretError.new(secret_name: secret_name)
    end

    # Check scope is allowed if scopes are defined
    allowed_scopes = registration[:scopes] || registration["scopes"] || []
    if allowed_scopes.any? && scope && !allowed_scopes.include?(scope)
      raise SecretsProxyError.new(
        secret_name: secret_name,
        reason: "Scope '#{scope}' not allowed. Allowed scopes: #{allowed_scopes.join(", ")}"
      )
    end

    # Generate token
    token = generate_token
    token_ttl = ttl || @config.fetch(:token_ttl, DEFAULT_TOKEN_TTL)
    expires_at = Time.now + token_ttl

    token_data = {
      token: token,
      secret_name: secret_name,
      scope: scope,
      expires_at: expires_at,
      created_at: Time.now,
      env_var: registration[:env_var] || registration["env_var"],
      used: false
    }

    @active_tokens[token] = token_data

    Aidp.log_debug("security.proxy", "token_issued",
      secret_name: secret_name,
      scope: scope,
      expires_in: token_ttl,
      token_prefix: token[0..7])

    {
      token: token,
      expires_at: expires_at.iso8601,
      secret_name: secret_name,
      scope: scope,
      ttl: token_ttl
    }
  end
end

#reset!Object

Reset proxy state (primarily for testing)



281
282
283
284
285
286
# File 'lib/aidp/security/secrets_proxy.rb', line 281

def reset!
  @mutex.synchronize do
    @active_tokens.clear
    @token_usage_log.clear
  end
end

#revoke_all_for_secret(secret_name) ⇒ Integer

Revoke all tokens for a specific secret

Parameters:

  • secret_name (String)

    The secret name

Returns:

  • (Integer)

    Number of tokens revoked



172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/aidp/security/secrets_proxy.rb', line 172

def revoke_all_for_secret(secret_name)
  @mutex.synchronize do
    tokens_to_revoke = @active_tokens.select { |_t, d| d[:secret_name] == secret_name }
    count = tokens_to_revoke.size

    tokens_to_revoke.keys.each { |t| @active_tokens.delete(t) }

    Aidp.log_info("security.proxy", "tokens_revoked_for_secret",
      secret_name: secret_name,
      count: count)

    count
  end
end

#revoke_token(token) ⇒ Boolean

Revoke a token before it expires

Parameters:

  • token (String)

    The token to revoke

Returns:

  • (Boolean)

    true if revoked, false if not found



157
158
159
160
161
162
163
164
165
166
167
# File 'lib/aidp/security/secrets_proxy.rb', line 157

def revoke_token(token)
  @mutex.synchronize do
    if @active_tokens.delete(token)
      Aidp.log_info("security.proxy", "token_revoked",
        token_prefix: token[0..7])
      true
    else
      false
    end
  end
end

#sanitized_environment(base_env = ENV.to_h) ⇒ Hash

Build a sanitized environment hash with registered secrets stripped

Parameters:

  • base_env (Hash) (defaults to: ENV.to_h)

    The base environment (defaults to ENV.to_h)

Returns:

  • (Hash)

    Environment with registered secrets removed



235
236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/aidp/security/secrets_proxy.rb', line 235

def sanitized_environment(base_env = ENV.to_h)
  env = base_env.dup
  vars_to_strip = @registry.env_vars_to_strip

  vars_to_strip.each do |var|
    if env.key?(var)
      env.delete(var)
      Aidp.log_debug("security.proxy", "env_var_stripped",
        env_var: var)
    end
  end

  env
end

#usage_log(limit: 50) ⇒ Array<Hash>

Get usage audit log

Parameters:

  • limit (Integer) (defaults to: 50)

    Maximum entries to return

Returns:

  • (Array<Hash>)

    Recent token usage records



226
227
228
229
230
# File 'lib/aidp/security/secrets_proxy.rb', line 226

def usage_log(limit: 50)
  @mutex.synchronize do
    @token_usage_log.last(limit)
  end
end

#with_sanitized_environment { ... } ⇒ Object

Execute a block with a sanitized environment Registered secrets are stripped from ENV during execution

Yields:

  • The block to execute in sanitized environment

Returns:

  • (Object)

    The result of the block



254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/aidp/security/secrets_proxy.rb', line 254

def with_sanitized_environment
  original_env = {}
  vars_to_strip = @registry.env_vars_to_strip

  # Save and clear registered secrets
  vars_to_strip.each do |var|
    if ENV.key?(var)
      original_env[var] = ENV[var]
      ENV.delete(var)
    end
  end

  Aidp.log_debug("security.proxy", "environment_sanitized",
    stripped_count: original_env.size)

  begin
    yield
  ensure
    # Restore secrets
    original_env.each { |k, v| ENV[k] = v }

    Aidp.log_debug("security.proxy", "environment_restored",
      restored_count: original_env.size)
  end
end