sxn 0.2.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 (156) hide show
  1. checksums.yaml +7 -0
  2. data/.gem_rbs_collection/addressable/2.8/.rbs_meta.yaml +9 -0
  3. data/.gem_rbs_collection/addressable/2.8/addressable.rbs +62 -0
  4. data/.gem_rbs_collection/async/2.12/.rbs_meta.yaml +9 -0
  5. data/.gem_rbs_collection/async/2.12/async.rbs +119 -0
  6. data/.gem_rbs_collection/async/2.12/kernel.rbs +5 -0
  7. data/.gem_rbs_collection/async/2.12/manifest.yaml +7 -0
  8. data/.gem_rbs_collection/bcrypt/3.1/.rbs_meta.yaml +9 -0
  9. data/.gem_rbs_collection/bcrypt/3.1/bcrypt.rbs +47 -0
  10. data/.gem_rbs_collection/bcrypt/3.1/manifest.yaml +2 -0
  11. data/.gem_rbs_collection/bigdecimal/3.1/.rbs_meta.yaml +9 -0
  12. data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal-math.rbs +119 -0
  13. data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal.rbs +1630 -0
  14. data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +9 -0
  15. data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +4 -0
  16. data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +26 -0
  17. data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +4 -0
  18. data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +65 -0
  19. data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +249 -0
  20. data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +5 -0
  21. data/.gem_rbs_collection/diff-lcs/1.5/.rbs_meta.yaml +9 -0
  22. data/.gem_rbs_collection/diff-lcs/1.5/diff-lcs.rbs +11 -0
  23. data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +9 -0
  24. data/.gem_rbs_collection/listen/3.9/listen.rbs +25 -0
  25. data/.gem_rbs_collection/listen/3.9/listener.rbs +24 -0
  26. data/.gem_rbs_collection/mini_mime/0.1/.rbs_meta.yaml +9 -0
  27. data/.gem_rbs_collection/mini_mime/0.1/mini_mime.rbs +14 -0
  28. data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +9 -0
  29. data/.gem_rbs_collection/parallel/1.20/parallel.rbs +86 -0
  30. data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +9 -0
  31. data/.gem_rbs_collection/rake/13.0/manifest.yaml +2 -0
  32. data/.gem_rbs_collection/rake/13.0/rake.rbs +39 -0
  33. data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +9 -0
  34. data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +822 -0
  35. data/.gem_rbs_collection/sqlite3/2.0/.rbs_meta.yaml +9 -0
  36. data/.gem_rbs_collection/sqlite3/2.0/database.rbs +20 -0
  37. data/.gem_rbs_collection/sqlite3/2.0/pragmas.rbs +5 -0
  38. data/.rspec +4 -0
  39. data/.rubocop.yml +121 -0
  40. data/.simplecov +51 -0
  41. data/CHANGELOG.md +49 -0
  42. data/Gemfile +24 -0
  43. data/Gemfile.lock +329 -0
  44. data/LICENSE.txt +21 -0
  45. data/README.md +225 -0
  46. data/Rakefile +54 -0
  47. data/Steepfile +50 -0
  48. data/bin/sxn +6 -0
  49. data/lib/sxn/CLI.rb +275 -0
  50. data/lib/sxn/commands/init.rb +137 -0
  51. data/lib/sxn/commands/projects.rb +350 -0
  52. data/lib/sxn/commands/rules.rb +435 -0
  53. data/lib/sxn/commands/sessions.rb +300 -0
  54. data/lib/sxn/commands/worktrees.rb +416 -0
  55. data/lib/sxn/commands.rb +13 -0
  56. data/lib/sxn/config/config_cache.rb +295 -0
  57. data/lib/sxn/config/config_discovery.rb +242 -0
  58. data/lib/sxn/config/config_validator.rb +562 -0
  59. data/lib/sxn/config.rb +259 -0
  60. data/lib/sxn/core/config_manager.rb +290 -0
  61. data/lib/sxn/core/project_manager.rb +307 -0
  62. data/lib/sxn/core/rules_manager.rb +306 -0
  63. data/lib/sxn/core/session_manager.rb +336 -0
  64. data/lib/sxn/core/worktree_manager.rb +281 -0
  65. data/lib/sxn/core.rb +13 -0
  66. data/lib/sxn/database/errors.rb +29 -0
  67. data/lib/sxn/database/session_database.rb +691 -0
  68. data/lib/sxn/database.rb +24 -0
  69. data/lib/sxn/errors.rb +76 -0
  70. data/lib/sxn/rules/base_rule.rb +367 -0
  71. data/lib/sxn/rules/copy_files_rule.rb +346 -0
  72. data/lib/sxn/rules/errors.rb +28 -0
  73. data/lib/sxn/rules/project_detector.rb +871 -0
  74. data/lib/sxn/rules/rules_engine.rb +485 -0
  75. data/lib/sxn/rules/setup_commands_rule.rb +307 -0
  76. data/lib/sxn/rules/template_rule.rb +262 -0
  77. data/lib/sxn/rules.rb +148 -0
  78. data/lib/sxn/runtime_validations.rb +96 -0
  79. data/lib/sxn/security/secure_command_executor.rb +364 -0
  80. data/lib/sxn/security/secure_file_copier.rb +478 -0
  81. data/lib/sxn/security/secure_path_validator.rb +258 -0
  82. data/lib/sxn/security.rb +15 -0
  83. data/lib/sxn/templates/common/gitignore.liquid +99 -0
  84. data/lib/sxn/templates/common/session-info.md.liquid +58 -0
  85. data/lib/sxn/templates/errors.rb +36 -0
  86. data/lib/sxn/templates/javascript/README.md.liquid +59 -0
  87. data/lib/sxn/templates/javascript/session-info.md.liquid +206 -0
  88. data/lib/sxn/templates/rails/CLAUDE.md.liquid +78 -0
  89. data/lib/sxn/templates/rails/database.yml.liquid +31 -0
  90. data/lib/sxn/templates/rails/session-info.md.liquid +144 -0
  91. data/lib/sxn/templates/template_engine.rb +346 -0
  92. data/lib/sxn/templates/template_processor.rb +279 -0
  93. data/lib/sxn/templates/template_security.rb +410 -0
  94. data/lib/sxn/templates/template_variables.rb +713 -0
  95. data/lib/sxn/templates.rb +28 -0
  96. data/lib/sxn/ui/output.rb +103 -0
  97. data/lib/sxn/ui/progress_bar.rb +91 -0
  98. data/lib/sxn/ui/prompt.rb +116 -0
  99. data/lib/sxn/ui/table.rb +183 -0
  100. data/lib/sxn/ui.rb +12 -0
  101. data/lib/sxn/version.rb +5 -0
  102. data/lib/sxn.rb +63 -0
  103. data/rbs_collection.lock.yaml +180 -0
  104. data/rbs_collection.yaml +39 -0
  105. data/scripts/test.sh +31 -0
  106. data/sig/external/liquid.rbs +116 -0
  107. data/sig/external/thor.rbs +99 -0
  108. data/sig/external/tty.rbs +71 -0
  109. data/sig/sxn/cli.rbs +46 -0
  110. data/sig/sxn/commands/init.rbs +38 -0
  111. data/sig/sxn/commands/projects.rbs +72 -0
  112. data/sig/sxn/commands/rules.rbs +95 -0
  113. data/sig/sxn/commands/sessions.rbs +62 -0
  114. data/sig/sxn/commands/worktrees.rbs +82 -0
  115. data/sig/sxn/commands.rbs +6 -0
  116. data/sig/sxn/config/config_cache.rbs +67 -0
  117. data/sig/sxn/config/config_discovery.rbs +64 -0
  118. data/sig/sxn/config/config_validator.rbs +64 -0
  119. data/sig/sxn/config.rbs +74 -0
  120. data/sig/sxn/core/config_manager.rbs +67 -0
  121. data/sig/sxn/core/project_manager.rbs +52 -0
  122. data/sig/sxn/core/rules_manager.rbs +54 -0
  123. data/sig/sxn/core/session_manager.rbs +59 -0
  124. data/sig/sxn/core/worktree_manager.rbs +50 -0
  125. data/sig/sxn/core.rbs +87 -0
  126. data/sig/sxn/database/errors.rbs +37 -0
  127. data/sig/sxn/database/session_database.rbs +151 -0
  128. data/sig/sxn/database.rbs +83 -0
  129. data/sig/sxn/errors.rbs +89 -0
  130. data/sig/sxn/rules/base_rule.rbs +137 -0
  131. data/sig/sxn/rules/copy_files_rule.rbs +65 -0
  132. data/sig/sxn/rules/errors.rbs +33 -0
  133. data/sig/sxn/rules/project_detector.rbs +115 -0
  134. data/sig/sxn/rules/rules_engine.rbs +118 -0
  135. data/sig/sxn/rules/setup_commands_rule.rbs +60 -0
  136. data/sig/sxn/rules/template_rule.rbs +44 -0
  137. data/sig/sxn/rules.rbs +287 -0
  138. data/sig/sxn/runtime_validations.rbs +16 -0
  139. data/sig/sxn/security/secure_command_executor.rbs +63 -0
  140. data/sig/sxn/security/secure_file_copier.rbs +79 -0
  141. data/sig/sxn/security/secure_path_validator.rbs +30 -0
  142. data/sig/sxn/security.rbs +128 -0
  143. data/sig/sxn/templates/errors.rbs +43 -0
  144. data/sig/sxn/templates/template_engine.rbs +50 -0
  145. data/sig/sxn/templates/template_processor.rbs +44 -0
  146. data/sig/sxn/templates/template_security.rbs +62 -0
  147. data/sig/sxn/templates/template_variables.rbs +103 -0
  148. data/sig/sxn/templates.rbs +104 -0
  149. data/sig/sxn/ui/output.rbs +50 -0
  150. data/sig/sxn/ui/progress_bar.rbs +39 -0
  151. data/sig/sxn/ui/prompt.rbs +38 -0
  152. data/sig/sxn/ui/table.rbs +43 -0
  153. data/sig/sxn/ui.rbs +63 -0
  154. data/sig/sxn/version.rbs +5 -0
  155. data/sig/sxn.rbs +29 -0
  156. metadata +635 -0
data/lib/sxn/rules.rb ADDED
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rules/errors"
4
+ require_relative "rules/base_rule"
5
+ require_relative "rules/copy_files_rule"
6
+ require_relative "rules/setup_commands_rule"
7
+ require_relative "rules/template_rule"
8
+ require_relative "rules/rules_engine"
9
+ require_relative "rules/project_detector"
10
+
11
+ module Sxn
12
+ # The Rules module provides a comprehensive system for automating project setup
13
+ # through configurable rules. It includes secure file copying, command execution,
14
+ # template processing, and intelligent project detection.
15
+ #
16
+ # @example Basic usage
17
+ # engine = Rules::RulesEngine.new("/path/to/project", "/path/to/session")
18
+ # detector = Rules::ProjectDetector.new("/path/to/project")
19
+ #
20
+ # # Detect project characteristics and suggest rules
21
+ # suggested_rules = detector.suggest_default_rules
22
+ #
23
+ # # Apply rules to set up the session
24
+ # result = engine.apply_rules(suggested_rules)
25
+ #
26
+ # if result.success?
27
+ # puts "Applied #{result.applied_rules.size} rules successfully"
28
+ # else
29
+ # puts "Failed to apply rules: #{result.errors}"
30
+ # engine.rollback_rules
31
+ # end
32
+ #
33
+ module Rules
34
+ # Get all available rule types
35
+ #
36
+ # @return [Array<String>] Available rule type names
37
+ def self.available_types
38
+ RulesEngine::RULE_TYPES.keys
39
+ end
40
+
41
+ # Create a rule instance from configuration
42
+ #
43
+ # @param name [String] Rule name
44
+ # @param type [String] Rule type
45
+ # @param config [Hash] Rule configuration
46
+ # @param project_path [String] Project root path
47
+ # @param session_path [String] Session directory path
48
+ # @param dependencies [Array<String>] Rule dependencies
49
+ # @return [BaseRule] Rule instance
50
+ # @raise [ArgumentError] if rule type is invalid
51
+ def self.create_rule(name, type, config, project_path, session_path, dependencies: [])
52
+ # Convert symbol to string for consistent lookup
53
+ type_key = type.to_s
54
+ rule_class = RulesEngine::RULE_TYPES[type_key]
55
+ raise ArgumentError, "Invalid rule type: #{type}" unless rule_class
56
+
57
+ # Create rule instance - rule classes all follow BaseRule constructor pattern
58
+ case type_key
59
+ when "copy_files"
60
+ CopyFilesRule.new(name, config, project_path, session_path, dependencies: dependencies)
61
+ when "setup_commands"
62
+ SetupCommandsRule.new(name, config, project_path, session_path, dependencies: dependencies)
63
+ when "template"
64
+ TemplateRule.new(name, config, project_path, session_path, dependencies: dependencies)
65
+ else
66
+ raise ArgumentError, "Invalid rule type: #{type}"
67
+ end
68
+ end
69
+
70
+ # Validate a rules configuration hash
71
+ #
72
+ # @param rules_config [Hash] Rules configuration
73
+ # @param project_path [String] Project root path
74
+ # @param session_path [String] Session directory path
75
+ # @return [Boolean] true if valid
76
+ # @raise [ValidationError] if configuration is invalid
77
+ def self.validate_configuration(rules_config, project_path, session_path)
78
+ engine = RulesEngine.new(project_path, session_path)
79
+ engine.validate_rules_config(rules_config)
80
+ true
81
+ end
82
+
83
+ # Get rule type information
84
+ #
85
+ # @return [Hash] Rule type information including descriptions and supported options
86
+ def self.rule_type_info
87
+ {
88
+ "copy_files" => {
89
+ description: "Securely copy or symlink files with permission control and optional encryption",
90
+ config_schema: {
91
+ "files" => {
92
+ type: "array",
93
+ required: true,
94
+ description: "List of files to copy",
95
+ items: {
96
+ "source" => { type: "string", required: true, description: "Source file path" },
97
+ "destination" => { type: "string", required: false,
98
+ description: "Destination path (defaults to source)" },
99
+ "strategy" => { type: "string", required: false, enum: %w[copy symlink], default: "copy" },
100
+ "permissions" => { type: "string", required: false, description: "File permissions (e.g., '0600')" },
101
+ "encrypt" => { type: "boolean", required: false, default: false },
102
+ "required" => { type: "boolean", required: false, default: true }
103
+ }
104
+ }
105
+ }
106
+ },
107
+ "setup_commands" => {
108
+ description: "Execute project setup commands securely with environment control",
109
+ config_schema: {
110
+ "commands" => {
111
+ type: "array",
112
+ required: true,
113
+ description: "List of commands to execute",
114
+ items: {
115
+ "command" => { type: "array", required: true, description: "Command and arguments" },
116
+ "env" => { type: "object", required: false, description: "Environment variables" },
117
+ "timeout" => { type: "integer", required: false, default: 60, maximum: 1800 },
118
+ "condition" => { type: "string", required: false, description: "Execution condition" },
119
+ "working_directory" => { type: "string", required: false, description: "Working directory" },
120
+ "description" => { type: "string", required: false, description: "Command description" },
121
+ "required" => { type: "boolean", required: false, default: true }
122
+ }
123
+ },
124
+ "continue_on_failure" => { type: "boolean", required: false, default: false }
125
+ }
126
+ },
127
+ "template" => {
128
+ description: "Process and apply template files with variable substitution",
129
+ config_schema: {
130
+ "templates" => {
131
+ type: "array",
132
+ required: true,
133
+ description: "List of templates to process",
134
+ items: {
135
+ "source" => { type: "string", required: true, description: "Template file path" },
136
+ "destination" => { type: "string", required: true, description: "Output file path" },
137
+ "variables" => { type: "object", required: false, description: "Additional template variables" },
138
+ "engine" => { type: "string", required: false, enum: ["liquid"], default: "liquid" },
139
+ "required" => { type: "boolean", required: false, default: true },
140
+ "overwrite" => { type: "boolean", required: false, default: false }
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sxn
4
+ # Runtime validation helpers for Thor commands and type safety
5
+ module RuntimeValidations
6
+ class << self
7
+ # Validate Thor command arguments at runtime
8
+ def validate_thor_arguments(command_name, args, options, validations)
9
+ # Validate argument count
10
+ if validations[:args]
11
+ count_range = validations[:args][:count]
12
+ raise ArgumentError, "#{command_name} expects #{count_range} arguments, got #{args.size}" if count_range && !count_range.include?(args.size)
13
+
14
+ # Validate argument types
15
+ if validations[:args][:types]
16
+ args.each_with_index do |arg, index|
17
+ expected_types = Array(validations[:args][:types][index] || validations[:args][:types].last)
18
+ unless expected_types.any? { |type| arg.is_a?(type) }
19
+ raise TypeError, "#{command_name} argument #{index + 1} must be #{expected_types.join(" or ")}"
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ # Validate options
26
+ if validations[:options]
27
+ options.each do |key, value|
28
+ validate_option_type(command_name, key, value, validations[:options][key.to_sym]) if validations[:options][key.to_sym]
29
+ end
30
+ end
31
+
32
+ true
33
+ end
34
+
35
+ # Validate and coerce types for runtime safety
36
+ def validate_and_coerce_type(value, target_type, context = nil)
37
+ case target_type.name
38
+ when "String"
39
+ value.to_s
40
+ when "Integer"
41
+ Integer(value)
42
+ when "Float"
43
+ Float(value)
44
+ when "TrueClass", "FalseClass", "Boolean"
45
+ !!value
46
+ when "Array"
47
+ Array(value)
48
+ when "Hash"
49
+ value.is_a?(Hash) ? value : {}
50
+ else
51
+ value
52
+ end
53
+ rescue StandardError => e
54
+ raise TypeError, "Cannot coerce #{value.class} to #{target_type} in #{context}: #{e.message}"
55
+ end
56
+
57
+ # Validate template variables for Liquid templates
58
+ def validate_template_variables(variables)
59
+ return {} unless variables.is_a?(Hash)
60
+
61
+ # Ensure all required variable categories exist
62
+ validated = {
63
+ session: variables[:session] || {},
64
+ project: variables[:project] || {},
65
+ git: variables[:git] || {},
66
+ user: variables[:user] || {},
67
+ environment: variables[:environment] || {},
68
+ timestamp: variables[:timestamp] || {},
69
+ custom: variables[:custom] || {}
70
+ }
71
+
72
+ # Ensure no nil values in the hash
73
+ validated.each do |key, value|
74
+ validated[key] = {} unless value.is_a?(Hash)
75
+ end
76
+
77
+ validated
78
+ end
79
+
80
+ private
81
+
82
+ def validate_option_type(command_name, key, value, expected_type)
83
+ case expected_type
84
+ when :boolean
85
+ raise TypeError, "#{command_name} option --#{key} must be boolean" unless [true, false, nil].include?(value)
86
+ when :string
87
+ raise TypeError, "#{command_name} option --#{key} must be a string" unless value.nil? || value.is_a?(String)
88
+ when :integer
89
+ raise TypeError, "#{command_name} option --#{key} must be an integer" unless value.nil? || value.is_a?(Integer)
90
+ when :array
91
+ raise TypeError, "#{command_name} option --#{key} must be an array" unless value.nil? || value.is_a?(Array)
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,364 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "open3"
5
+ require "ostruct"
6
+ require "timeout"
7
+ require "json"
8
+
9
+ module Sxn
10
+ module Security
11
+ # SecureCommandExecutor provides secure command execution with strict controls.
12
+ # It prevents shell interpolation by using Process.spawn with arrays,
13
+ # whitelists allowed commands, cleans environment variables, and logs all executions.
14
+ #
15
+ # @example
16
+ # executor = SecureCommandExecutor.new("/path/to/project")
17
+ # result = executor.execute(["bundle", "install"], env: {"RAILS_ENV" => "development"})
18
+ # puts result.success? # => true/false
19
+ # puts result.stdout # => command output
20
+ #
21
+ class SecureCommandExecutor
22
+ # Command execution result
23
+ class CommandResult
24
+ attr_reader :exit_status, :stdout, :stderr, :command, :duration
25
+
26
+ def initialize(exit_status, stdout, stderr, command, duration)
27
+ @exit_status = exit_status
28
+ @stdout = stdout || ""
29
+ @stderr = stderr || ""
30
+ @command = command
31
+ @duration = duration
32
+ end
33
+
34
+ def success?
35
+ @exit_status.zero?
36
+ end
37
+
38
+ def failure?
39
+ !success?
40
+ end
41
+
42
+ def to_h
43
+ {
44
+ exit_status: @exit_status,
45
+ stdout: @stdout,
46
+ stderr: @stderr,
47
+ command: @command,
48
+ duration: @duration,
49
+ success: success?
50
+ }
51
+ end
52
+ end
53
+
54
+ # Whitelist of allowed commands with their expected paths
55
+ # Commands are mapped to either:
56
+ # - String: exact path to executable
57
+ # - Array: list of possible paths (first existing one is used)
58
+ # - Symbol: special handling required
59
+ ALLOWED_COMMANDS = {
60
+ # Ruby/Rails commands
61
+ "bundle" => %w[bundle /usr/local/bin/bundle /opt/homebrew/bin/bundle],
62
+ "gem" => %w[gem /usr/local/bin/gem /opt/homebrew/bin/gem],
63
+ "ruby" => %w[ruby /usr/local/bin/ruby /opt/homebrew/bin/ruby],
64
+ "rails" => :rails_command, # Special handling for bin/rails vs rails
65
+
66
+ # Node.js commands
67
+ "npm" => %w[npm /usr/local/bin/npm /opt/homebrew/bin/npm],
68
+ "yarn" => %w[yarn /usr/local/bin/yarn /opt/homebrew/bin/yarn],
69
+ "pnpm" => %w[pnpm /usr/local/bin/pnpm /opt/homebrew/bin/pnpm],
70
+ "node" => %w[node /usr/local/bin/node /opt/homebrew/bin/node],
71
+
72
+ # Git commands
73
+ "git" => %w[git /usr/bin/git /usr/local/bin/git /opt/homebrew/bin/git],
74
+
75
+ # Database commands
76
+ "psql" => %w[psql /usr/local/bin/psql /opt/homebrew/bin/psql],
77
+ "mysql" => %w[mysql /usr/local/bin/mysql /opt/homebrew/bin/mysql],
78
+ "sqlite3" => %w[sqlite3 /usr/bin/sqlite3 /usr/local/bin/sqlite3],
79
+
80
+ # Development tools
81
+ "make" => %w[make /usr/bin/make],
82
+ "curl" => %w[curl /usr/bin/curl /usr/local/bin/curl],
83
+ "wget" => %w[wget /usr/bin/wget /usr/local/bin/wget],
84
+
85
+ # Project-specific executables (resolved relative to project)
86
+ "bin/rails" => :project_executable,
87
+ "bin/setup" => :project_executable,
88
+ "bin/dev" => :project_executable,
89
+ "bin/test" => :project_executable,
90
+ "./bin/rails" => :project_executable,
91
+ "./bin/setup" => :project_executable
92
+ }.freeze
93
+
94
+ # Environment variables that are safe to preserve
95
+ SAFE_ENV_VARS = %w[
96
+ PATH
97
+ HOME
98
+ USER
99
+ LANG
100
+ LC_ALL
101
+ TZ
102
+ TMPDIR
103
+ RAILS_ENV
104
+ NODE_ENV
105
+ BUNDLE_GEMFILE
106
+ GEM_HOME
107
+ GEM_PATH
108
+ RBENV_VERSION
109
+ NVM_DIR
110
+ NVM_BIN
111
+ SSL_CERT_FILE
112
+ SSL_CERT_DIR
113
+ ].freeze
114
+
115
+ # Maximum command execution timeout (in seconds)
116
+ MAX_TIMEOUT = 300 # 5 minutes
117
+
118
+ # @param project_root [String] The absolute path to the project root directory
119
+ # @param logger [Logger] Optional logger for audit trail
120
+ def initialize(project_root, logger: nil)
121
+ @project_root = File.realpath(project_root)
122
+ @logger = logger || Sxn.logger
123
+ @command_whitelist = build_command_whitelist
124
+ rescue Errno::ENOENT
125
+ raise ArgumentError, "Project root does not exist: #{project_root}"
126
+ end
127
+
128
+ # Executes a command securely with strict controls
129
+ #
130
+ # @param command [Array<String>] Command and arguments as an array
131
+ # @param env [Hash] Environment variables to set
132
+ # @param timeout [Integer] Maximum execution time in seconds
133
+ # @param chdir [String] Directory to run command in (must be within project)
134
+ # @return [CommandResult] The execution result
135
+ # @raise [CommandExecutionError] if command is not allowed or execution fails
136
+ def execute(command, env: {}, timeout: 30, chdir: nil)
137
+ raise ArgumentError, "Command must be an array" unless command.is_a?(Array)
138
+ raise ArgumentError, "Command cannot be empty" if command.empty?
139
+ raise ArgumentError, "Timeout must be positive" unless timeout.positive? && timeout <= MAX_TIMEOUT
140
+
141
+ validated_command = validate_and_resolve_command(command)
142
+ safe_env = build_safe_environment(env)
143
+ work_dir = chdir ? validate_work_directory(chdir) : @project_root
144
+
145
+ start_time = Time.now
146
+ audit_log("EXEC_START", validated_command, work_dir, safe_env.keys)
147
+
148
+ begin
149
+ result = execute_with_timeout(validated_command, safe_env, work_dir, timeout)
150
+ duration = Time.now - start_time
151
+
152
+ audit_log("EXEC_COMPLETE", validated_command, work_dir, {
153
+ exit_status: result.exit_status,
154
+ duration: duration,
155
+ success: result.success?
156
+ })
157
+
158
+ CommandResult.new(result.exit_status, result.stdout, result.stderr, validated_command, duration)
159
+ rescue StandardError => e
160
+ duration = Time.now - start_time
161
+ audit_log("EXEC_ERROR", validated_command, work_dir, {
162
+ error: e.class.name,
163
+ message: e.message,
164
+ duration: duration
165
+ })
166
+ raise CommandExecutionError, "Command execution failed: #{e.message}"
167
+ end
168
+ end
169
+
170
+ # Checks if a command is allowed without executing it
171
+ #
172
+ # @param command [Array<String>] Command and arguments as an array
173
+ # @return [Boolean] true if the command is whitelisted
174
+ def command_allowed?(command)
175
+ return false unless command.is_a?(Array) && !command.empty?
176
+
177
+ begin
178
+ validate_and_resolve_command(command)
179
+ true
180
+ rescue CommandExecutionError
181
+ false
182
+ end
183
+ end
184
+
185
+ # Returns the list of allowed commands
186
+ #
187
+ # @return [Array<String>] List of allowed command names
188
+ def allowed_commands
189
+ @command_whitelist.keys.sort
190
+ end
191
+
192
+ private
193
+
194
+ # Validates that a command is whitelisted and resolves its path
195
+ def validate_and_resolve_command(command)
196
+ command_name = command.first
197
+
198
+ raise CommandExecutionError, "Command not whitelisted: #{command_name}" unless @command_whitelist.key?(command_name)
199
+
200
+ executable_path = @command_whitelist[command_name]
201
+
202
+ # Validate that the executable exists and is executable
203
+ unless File.exist?(executable_path) && File.executable?(executable_path)
204
+ raise CommandExecutionError, "Command executable not found or not executable: #{executable_path}"
205
+ end
206
+
207
+ # Return command with resolved executable path
208
+ [executable_path] + command[1..]
209
+ end
210
+
211
+ # Builds the command whitelist by resolving paths
212
+ def build_command_whitelist
213
+ whitelist = {}
214
+
215
+ ALLOWED_COMMANDS.each do |cmd_name, path_spec|
216
+ resolved_path = case path_spec
217
+ when String
218
+ path_spec if File.exist?(path_spec) && File.executable?(path_spec)
219
+ when Array
220
+ path_spec.find { |path| File.exist?(path) && File.executable?(path) }
221
+ when :rails_command
222
+ resolve_rails_command
223
+ when :project_executable
224
+ resolve_project_executable(cmd_name)
225
+ end
226
+
227
+ whitelist[cmd_name] = resolved_path if resolved_path
228
+ end
229
+
230
+ whitelist
231
+ end
232
+
233
+ # Special handling for Rails command (bin/rails vs global rails)
234
+ def resolve_rails_command
235
+ bin_rails = File.join(@project_root, "bin", "rails")
236
+ return bin_rails if File.exist?(bin_rails) && File.executable?(bin_rails)
237
+
238
+ # Fall back to global rails command
239
+ %w[rails /usr/local/bin/rails /opt/homebrew/bin/rails].find do |path|
240
+ File.exist?(path) && File.executable?(path)
241
+ end
242
+ end
243
+
244
+ # Resolves project-specific executables
245
+ def resolve_project_executable(cmd_name)
246
+ # Remove leading ./ if present
247
+ clean_cmd = cmd_name.sub(%r{\A\./}, "")
248
+ executable_path = File.join(@project_root, clean_cmd)
249
+
250
+ return executable_path if File.exist?(executable_path) && File.executable?(executable_path)
251
+
252
+ nil
253
+ end
254
+
255
+ # Builds a safe environment by filtering and cleaning variables
256
+ def build_safe_environment(user_env)
257
+ safe_env = {}
258
+
259
+ # Start with safe environment variables from current environment
260
+ SAFE_ENV_VARS.each do |var|
261
+ safe_env[var] = ENV[var] if ENV.key?(var)
262
+ end
263
+
264
+ # Add user-provided environment variables (with validation)
265
+ user_env.each do |key, value|
266
+ key_str = key.to_s
267
+ value_str = value.to_s
268
+
269
+ # Validate environment variable names (only alphanumeric and underscore)
270
+ raise CommandExecutionError, "Invalid environment variable name: #{key_str}" unless key_str.match?(/\A[A-Z_][A-Z0-9_]*\z/)
271
+
272
+ # Validate environment variable values (no null bytes)
273
+ raise CommandExecutionError, "Environment variable contains null bytes: #{key_str}" if value_str.include?("\x00")
274
+
275
+ safe_env[key_str] = value_str
276
+ end
277
+
278
+ safe_env
279
+ end
280
+
281
+ # Validates the working directory
282
+ def validate_work_directory(chdir)
283
+ path_validator = SecurePathValidator.new(@project_root)
284
+ validated_path = path_validator.validate_path(chdir)
285
+
286
+ raise CommandExecutionError, "Working directory does not exist: #{chdir}" unless File.directory?(validated_path)
287
+
288
+ validated_path
289
+ end
290
+
291
+ # Executes command with timeout and captures output
292
+ def execute_with_timeout(command, env, chdir, timeout)
293
+ stdout_r, stdout_w = IO.pipe
294
+ stderr_r, stderr_w = IO.pipe
295
+
296
+ begin
297
+ pid = Process.spawn(
298
+ env,
299
+ *command,
300
+ chdir: chdir,
301
+ out: stdout_w,
302
+ err: stderr_w,
303
+ unsetenv_others: true, # Clear all other environment variables
304
+ close_others: true # Close other file descriptors
305
+ )
306
+
307
+ stdout_w.close
308
+ stderr_w.close
309
+
310
+ # Wait for process with timeout
311
+ begin
312
+ Timeout.timeout(timeout) do
313
+ Process.wait(pid)
314
+ end
315
+ rescue Timeout::Error
316
+ Process.kill("TERM", pid)
317
+ sleep(1)
318
+ begin
319
+ Process.kill("KILL", pid)
320
+ rescue StandardError
321
+ nil
322
+ end
323
+ begin
324
+ Process.wait(pid)
325
+ rescue StandardError
326
+ nil
327
+ end
328
+ raise CommandExecutionError, "Command timed out after #{timeout} seconds"
329
+ end
330
+
331
+ exit_status = $CHILD_STATUS.exitstatus
332
+ stdout = stdout_r.read
333
+ stderr = stderr_r.read
334
+
335
+ OpenStruct.new(exit_status: exit_status, stdout: stdout, stderr: stderr)
336
+ ensure
337
+ [stdout_r, stdout_w, stderr_r, stderr_w].each do |io|
338
+ io.close
339
+ rescue StandardError
340
+ nil
341
+ end
342
+ end
343
+ end
344
+
345
+ # Logs command execution for audit trail
346
+ def audit_log(event, command, chdir, details = {})
347
+ return unless @logger
348
+
349
+ # Ensure details is a hash
350
+ details = {} unless details.is_a?(Hash)
351
+
352
+ log_entry = {
353
+ timestamp: Time.now.iso8601,
354
+ event: event,
355
+ command: command.is_a?(Array) ? command.first : command.to_s, # Only log the executable, not full command for security
356
+ chdir: chdir,
357
+ pid: Process.pid
358
+ }.merge(details)
359
+
360
+ @logger.info("SECURITY_AUDIT: #{log_entry.to_json}")
361
+ end
362
+ end
363
+ end
364
+ end