Class: Aidp::CLI::SecurityCommand

Inherits:
Object
  • Object
show all
Defined in:
lib/aidp/cli/security_command.rb

Overview

CLI commands for security management

Provides commands for:

  • aidp security status Show current security posture

  • aidp security register <name> Register a secret with the proxy

  • aidp security unregister <name> Remove a registered secret

  • aidp security list List registered secrets (names only)

  • aidp security audit Run security audit (RSpec tests)

Constant Summary collapse

AUDIT_TIMEOUT_SECONDS =

Default timeout for audit command (5 minutes)

300

Instance Method Summary collapse

Constructor Details

#initialize(project_dir: Dir.pwd, prompt: TTY::Prompt.new) ⇒ SecurityCommand

Returns a new instance of SecurityCommand.



19
20
21
22
# File 'lib/aidp/cli/security_command.rb', line 19

def initialize(project_dir: Dir.pwd, prompt: TTY::Prompt.new)
  @project_dir = project_dir
  @prompt = prompt
end

Instance Method Details

#run(args) ⇒ Integer

Run security command

Parameters:

  • args (Array<String>)

    Command arguments

Returns:

  • (Integer)

    Exit code



28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/aidp/cli/security_command.rb', line 28

def run(args)
  subcommand = args.shift

  case subcommand
  when "status"
    run_status
  when "register", "register-secret"
    secret_name = args.shift
    unless secret_name
      @prompt.error("Error: secret name required")
      @prompt.say("Usage: aidp security register <name> [--env-var VAR_NAME]")
      return 1
    end
    # Parse optional --env-var flag
    env_var = parse_env_var_option(args) || secret_name
    run_register(secret_name, env_var)
  when "unregister"
    secret_name = args.shift
    unless secret_name
      @prompt.error("Error: secret name required")
      @prompt.say("Usage: aidp security unregister <name>")
      return 1
    end
    run_unregister(secret_name)
  when "list", "secrets"
    run_list
  when "audit"
    run_audit(args)
  when "proxy-status"
    run_proxy_status
  when nil, "help", "--help", "-h"
    show_help
    0
  else
    @prompt.error("Unknown subcommand: #{subcommand}")
    show_help
    1
  end
end

#run_audit(args) ⇒ Object

Run audit command - run security audit tests



321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
# File 'lib/aidp/cli/security_command.rb', line 321

def run_audit(args)
  Aidp.log_info("security_cli", "running_audit")

  @prompt.say("\nRunning Security Audit...")
  @prompt.say("=" * 40)

  # Check for RSpec
  rspec_path = File.join(@project_dir, "spec", "aidp", "security")

  unless Dir.exist?(rspec_path)
    @prompt.warn("Security spec directory not found: #{rspec_path}")
    @prompt.say("Creating security audit scenarios...")

    # Create the directory
    FileUtils.mkdir_p(rspec_path)
    @prompt.ok("Created #{rspec_path}")
  end

  # Run RSpec for security specs
  @prompt.say("\nRunning security RSpec tests...")

  # Check if there are any spec files
  spec_files = Dir.glob(File.join(rspec_path, "**/*_spec.rb"))

  if spec_files.empty?
    @prompt.warn("No security spec files found")
    @prompt.say("Add security tests to: #{rspec_path}")
    return 0
  end

  # Run RSpec with timeout protection
  cmd = "bundle exec rspec #{rspec_path} --format documentation"
  @prompt.say("$ #{cmd}\n")
  @prompt.say("(timeout: #{AUDIT_TIMEOUT_SECONDS / 60} minutes)\n")

  exit_status = run_with_timeout(cmd, AUDIT_TIMEOUT_SECONDS)

  case exit_status
  when 0
    @prompt.ok("\nSecurity audit passed")
  when :timeout
    @prompt.error("\nSecurity audit timed out after #{AUDIT_TIMEOUT_SECONDS / 60} minutes")
    return 1
  else
    @prompt.error("\nSecurity audit failed")
  end

  exit_status.is_a?(Integer) ? exit_status : 1
end

#run_listObject

Run list command - list registered secrets



243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
# File 'lib/aidp/cli/security_command.rb', line 243

def run_list
  Aidp.log_debug("security_cli", "listing_secrets")

  registry = Aidp::Security.secrets_registry
  secrets = registry.list

  if secrets.empty?
    @prompt.say("\nNo secrets registered")
    @prompt.say("Use 'aidp security register <name>' to register a secret")
    return 0
  end

  @prompt.say("\n" + "=" * 60)
  @prompt.say("Registered Secrets")
  @prompt.say("=" * 60)

  headers = ["Name", "Env Var", "Has Value", "Scopes", "Registered"]
  rows = secrets.map do |secret|
    scopes = secret[:scopes] || []
    scope_str = scopes.any? ? scopes.join(", ") : "(any)"
    has_value = secret[:has_value] ? "\u2713" : "\u2717"
    registered = secret[:registered_at]&.split("T")&.first || "unknown"

    [secret[:name], secret[:env_var], has_value, scope_str, registered]
  end

  table = TTY::Table.new(headers, rows)
  @prompt.say(table.render(:unicode, padding: [0, 1]))

  @prompt.say("\n#{secrets.count} secret(s) registered")
  @prompt.say("")

  0
end

#run_proxy_statusObject

Run proxy-status command - show secrets proxy status



279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
# File 'lib/aidp/cli/security_command.rb', line 279

def run_proxy_status
  Aidp.log_debug("security_cli", "showing_proxy_status")

  proxy = Aidp::Security.secrets_proxy
  active_tokens = proxy.active_tokens_summary

  @prompt.say("\n" + "=" * 60)
  @prompt.say("Secrets Proxy Status")
  @prompt.say("=" * 60)

  @prompt.say("\nActive Tokens: #{active_tokens.count}")

  if active_tokens.any?
    headers = ["Secret", "Scope", "Expires In", "Used"]
    rows = active_tokens.map do |token|
      ttl = token[:remaining_ttl]
      expires = (ttl > 60) ? "#{ttl / 60}m #{ttl % 60}s" : "#{ttl}s"
      used = token[:used] ? "\u2713" : "\u2717"
      [token[:secret_name], token[:scope] || "(any)", expires, used]
    end

    table = TTY::Table.new(headers, rows)
    @prompt.say(table.render(:unicode, padding: [0, 1]))
  end

  # Show usage log
  usage_log = proxy.usage_log(limit: 10)
  if usage_log.any?
    @prompt.say("\nRecent Token Usage (last 10):")
    usage_log.each do |entry|
      @prompt.say("  - #{entry[:secret_name]} (#{entry[:scope] || "any"}) at #{entry[:used_at]}")
    end
  end

  @prompt.say("")
  0
end

#run_register(secret_name, env_var) ⇒ Object

Run register command - register a secret with the proxy



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/aidp/cli/security_command.rb', line 145

def run_register(secret_name, env_var)
  Aidp.log_info("security_cli", "registering_secret",
    name: secret_name,
    env_var: env_var)

  registry = Aidp::Security.secrets_registry

  # Check if already registered
  if registry.registered?(secret_name)
    @prompt.warn("Secret '#{secret_name}' is already registered")
    existing = registry.get(secret_name)
    @prompt.say("  Env var: #{existing[:env_var] || existing["env_var"]}")
    return 1
  end

  # Check if env var exists
  unless ENV.key?(env_var)
    @prompt.warn("Warning: Environment variable '#{env_var}' is not currently set")
    unless @prompt.yes?("Continue anyway?")
      return 1
    end
  end

  # Ask for optional description
  description = @prompt.ask("Description (optional):") do |q|
    q.required false
  end

  # Ask for optional scopes
  scopes = @prompt.ask("Allowed scopes (comma-separated, optional):") do |q|
    q.required false
  end
  scope_list = scopes&.split(",")&.map(&:strip)&.reject(&:empty?) || []

  begin
    result = registry.register(
      name: secret_name,
      env_var: env_var,
      description: description,
      scopes: scope_list
    )

    @prompt.ok("Secret '#{secret_name}' registered successfully")
    @prompt.say("  ID: #{result[:id]}")
    @prompt.say("  Env var: #{env_var}")
    @prompt.say("  Registered at: #{result[:registered_at]}")

    if scope_list.any?
      @prompt.say("  Scopes: #{scope_list.join(", ")}")
    end

    @prompt.say("\nThe secret value will be proxied through short-lived tokens.")
    @prompt.say("Agent processes will not have direct access to '#{env_var}'.")

    0
  rescue => e
    @prompt.error("Failed to register secret: #{e.message}")
    Aidp.log_error("security_cli", "registration_failed",
      name: secret_name,
      error: e.message)
    1
  end
end

#run_statusObject

Run status command - show current security posture



91
92
93
94
95
96
97
98
99
100
101
102
103
104
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
# File 'lib/aidp/cli/security_command.rb', line 91

def run_status
  Aidp.log_debug("security_cli", "showing_status")

  config = Aidp::Config.security_config(@project_dir)
  rule_of_two = config[:rule_of_two] || {}
  proxy_config = config[:secrets_proxy] || {}

  @prompt.say("\n" + "=" * 50)
  @prompt.say("AIDP Security Status")
  @prompt.say("=" * 50)

  # Rule of Two status
  @prompt.say("\n Rule of Two Enforcement")
  @prompt.say("-" * 30)
  enabled = rule_of_two.fetch(:enabled, true)
  policy = rule_of_two[:policy] || "strict"
  status_icon = enabled ? "\u2713" : "\u2717"
  @prompt.say("  Status: #{status_icon} #{enabled ? "Enabled" : "Disabled"}")
  @prompt.say("  Policy: #{policy}")

  # Enforcer status
  enforcer = Aidp::Security.enforcer
  summary = enforcer.status_summary
  @prompt.say("  Active work units: #{summary[:active_work_units]}")
  @prompt.say("  Completed work units: #{summary[:completed_work_units]}")

  # Secrets Proxy status
  @prompt.say("\n Secrets Proxy")
  @prompt.say("-" * 30)
  proxy_enabled = proxy_config.fetch(:enabled, true)
  token_ttl = proxy_config[:token_ttl] || 300
  status_icon = proxy_enabled ? "\u2713" : "\u2717"
  @prompt.say("  Status: #{status_icon} #{proxy_enabled ? "Enabled" : "Disabled"}")
  @prompt.say("  Token TTL: #{token_ttl} seconds")

  # Registered secrets count
  registry = Aidp::Security.secrets_registry
  secrets = registry.list
  @prompt.say("  Registered secrets: #{secrets.count}")

  # Active tokens
  proxy = Aidp::Security.secrets_proxy
  active_tokens = proxy.active_tokens_summary
  @prompt.say("  Active tokens: #{active_tokens.count}")

  @prompt.say("\n" + "=" * 50)
  @prompt.say("Use 'aidp security list' to see registered secrets")
  @prompt.say("Use 'aidp security register <name>' to add secrets")
  @prompt.say("")

  0
end

#run_unregister(secret_name) ⇒ Object

Run unregister command - remove a registered secret



210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
# File 'lib/aidp/cli/security_command.rb', line 210

def run_unregister(secret_name)
  Aidp.log_info("security_cli", "unregistering_secret", name: secret_name)

  registry = Aidp::Security.secrets_registry

  unless registry.registered?(secret_name)
    @prompt.error("Secret '#{secret_name}' is not registered")
    return 1
  end

  # Confirm unregistration
  unless @prompt.yes?("Are you sure you want to unregister '#{secret_name}'?")
    @prompt.say("Cancelled")
    return 0
  end

  # Revoke any active tokens for this secret
  proxy = Aidp::Security.secrets_proxy
  revoked_count = proxy.revoke_all_for_secret(secret_name)

  if registry.unregister(name: secret_name)
    @prompt.ok("Secret '#{secret_name}' unregistered")
    if revoked_count > 0
      @prompt.say("  Revoked #{revoked_count} active token(s)")
    end
    0
  else
    @prompt.error("Failed to unregister secret")
    1
  end
end

#show_helpObject

Show help message



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/aidp/cli/security_command.rb', line 69

def show_help
  @prompt.say("\nAIDP Security Management")
  @prompt.say("\n" + "=" * 40)
  @prompt.say("\nUsage:")
  @prompt.say("  aidp security status                 Show current security posture")
  @prompt.say("  aidp security register <name>        Register a secret with the proxy")
  @prompt.say("  aidp security unregister <name>      Remove a registered secret")
  @prompt.say("  aidp security list                   List registered secrets (names only)")
  @prompt.say("  aidp security proxy-status           Show secrets proxy status")
  @prompt.say("  aidp security audit                  Run security audit tests")
  @prompt.say("\nOptions for register:")
  @prompt.say("  --env-var VAR_NAME    Environment variable containing the secret")
  @prompt.say("                        (defaults to the secret name if not provided)")
  @prompt.say("\nExamples:")
  @prompt.say("  aidp security status")
  @prompt.say("  aidp security register GITHUB_TOKEN")
  @prompt.say("  aidp security register github_token --env-var GITHUB_TOKEN")
  @prompt.say("  aidp security list")
  @prompt.say("\n" + "=" * 40)
end