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.
- checksums.yaml +7 -0
- data/.gem_rbs_collection/addressable/2.8/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/addressable/2.8/addressable.rbs +62 -0
- data/.gem_rbs_collection/async/2.12/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/async/2.12/async.rbs +119 -0
- data/.gem_rbs_collection/async/2.12/kernel.rbs +5 -0
- data/.gem_rbs_collection/async/2.12/manifest.yaml +7 -0
- data/.gem_rbs_collection/bcrypt/3.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/bcrypt/3.1/bcrypt.rbs +47 -0
- data/.gem_rbs_collection/bcrypt/3.1/manifest.yaml +2 -0
- data/.gem_rbs_collection/bigdecimal/3.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal-math.rbs +119 -0
- data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal.rbs +1630 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +4 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +26 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +4 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +65 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +249 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +5 -0
- data/.gem_rbs_collection/diff-lcs/1.5/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/diff-lcs/1.5/diff-lcs.rbs +11 -0
- data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/listen/3.9/listen.rbs +25 -0
- data/.gem_rbs_collection/listen/3.9/listener.rbs +24 -0
- data/.gem_rbs_collection/mini_mime/0.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/mini_mime/0.1/mini_mime.rbs +14 -0
- data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/parallel/1.20/parallel.rbs +86 -0
- data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/rake/13.0/manifest.yaml +2 -0
- data/.gem_rbs_collection/rake/13.0/rake.rbs +39 -0
- data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +822 -0
- data/.gem_rbs_collection/sqlite3/2.0/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/sqlite3/2.0/database.rbs +20 -0
- data/.gem_rbs_collection/sqlite3/2.0/pragmas.rbs +5 -0
- data/.rspec +4 -0
- data/.rubocop.yml +121 -0
- data/.simplecov +51 -0
- data/CHANGELOG.md +49 -0
- data/Gemfile +24 -0
- data/Gemfile.lock +329 -0
- data/LICENSE.txt +21 -0
- data/README.md +225 -0
- data/Rakefile +54 -0
- data/Steepfile +50 -0
- data/bin/sxn +6 -0
- data/lib/sxn/CLI.rb +275 -0
- data/lib/sxn/commands/init.rb +137 -0
- data/lib/sxn/commands/projects.rb +350 -0
- data/lib/sxn/commands/rules.rb +435 -0
- data/lib/sxn/commands/sessions.rb +300 -0
- data/lib/sxn/commands/worktrees.rb +416 -0
- data/lib/sxn/commands.rb +13 -0
- data/lib/sxn/config/config_cache.rb +295 -0
- data/lib/sxn/config/config_discovery.rb +242 -0
- data/lib/sxn/config/config_validator.rb +562 -0
- data/lib/sxn/config.rb +259 -0
- data/lib/sxn/core/config_manager.rb +290 -0
- data/lib/sxn/core/project_manager.rb +307 -0
- data/lib/sxn/core/rules_manager.rb +306 -0
- data/lib/sxn/core/session_manager.rb +336 -0
- data/lib/sxn/core/worktree_manager.rb +281 -0
- data/lib/sxn/core.rb +13 -0
- data/lib/sxn/database/errors.rb +29 -0
- data/lib/sxn/database/session_database.rb +691 -0
- data/lib/sxn/database.rb +24 -0
- data/lib/sxn/errors.rb +76 -0
- data/lib/sxn/rules/base_rule.rb +367 -0
- data/lib/sxn/rules/copy_files_rule.rb +346 -0
- data/lib/sxn/rules/errors.rb +28 -0
- data/lib/sxn/rules/project_detector.rb +871 -0
- data/lib/sxn/rules/rules_engine.rb +485 -0
- data/lib/sxn/rules/setup_commands_rule.rb +307 -0
- data/lib/sxn/rules/template_rule.rb +262 -0
- data/lib/sxn/rules.rb +148 -0
- data/lib/sxn/runtime_validations.rb +96 -0
- data/lib/sxn/security/secure_command_executor.rb +364 -0
- data/lib/sxn/security/secure_file_copier.rb +478 -0
- data/lib/sxn/security/secure_path_validator.rb +258 -0
- data/lib/sxn/security.rb +15 -0
- data/lib/sxn/templates/common/gitignore.liquid +99 -0
- data/lib/sxn/templates/common/session-info.md.liquid +58 -0
- data/lib/sxn/templates/errors.rb +36 -0
- data/lib/sxn/templates/javascript/README.md.liquid +59 -0
- data/lib/sxn/templates/javascript/session-info.md.liquid +206 -0
- data/lib/sxn/templates/rails/CLAUDE.md.liquid +78 -0
- data/lib/sxn/templates/rails/database.yml.liquid +31 -0
- data/lib/sxn/templates/rails/session-info.md.liquid +144 -0
- data/lib/sxn/templates/template_engine.rb +346 -0
- data/lib/sxn/templates/template_processor.rb +279 -0
- data/lib/sxn/templates/template_security.rb +410 -0
- data/lib/sxn/templates/template_variables.rb +713 -0
- data/lib/sxn/templates.rb +28 -0
- data/lib/sxn/ui/output.rb +103 -0
- data/lib/sxn/ui/progress_bar.rb +91 -0
- data/lib/sxn/ui/prompt.rb +116 -0
- data/lib/sxn/ui/table.rb +183 -0
- data/lib/sxn/ui.rb +12 -0
- data/lib/sxn/version.rb +5 -0
- data/lib/sxn.rb +63 -0
- data/rbs_collection.lock.yaml +180 -0
- data/rbs_collection.yaml +39 -0
- data/scripts/test.sh +31 -0
- data/sig/external/liquid.rbs +116 -0
- data/sig/external/thor.rbs +99 -0
- data/sig/external/tty.rbs +71 -0
- data/sig/sxn/cli.rbs +46 -0
- data/sig/sxn/commands/init.rbs +38 -0
- data/sig/sxn/commands/projects.rbs +72 -0
- data/sig/sxn/commands/rules.rbs +95 -0
- data/sig/sxn/commands/sessions.rbs +62 -0
- data/sig/sxn/commands/worktrees.rbs +82 -0
- data/sig/sxn/commands.rbs +6 -0
- data/sig/sxn/config/config_cache.rbs +67 -0
- data/sig/sxn/config/config_discovery.rbs +64 -0
- data/sig/sxn/config/config_validator.rbs +64 -0
- data/sig/sxn/config.rbs +74 -0
- data/sig/sxn/core/config_manager.rbs +67 -0
- data/sig/sxn/core/project_manager.rbs +52 -0
- data/sig/sxn/core/rules_manager.rbs +54 -0
- data/sig/sxn/core/session_manager.rbs +59 -0
- data/sig/sxn/core/worktree_manager.rbs +50 -0
- data/sig/sxn/core.rbs +87 -0
- data/sig/sxn/database/errors.rbs +37 -0
- data/sig/sxn/database/session_database.rbs +151 -0
- data/sig/sxn/database.rbs +83 -0
- data/sig/sxn/errors.rbs +89 -0
- data/sig/sxn/rules/base_rule.rbs +137 -0
- data/sig/sxn/rules/copy_files_rule.rbs +65 -0
- data/sig/sxn/rules/errors.rbs +33 -0
- data/sig/sxn/rules/project_detector.rbs +115 -0
- data/sig/sxn/rules/rules_engine.rbs +118 -0
- data/sig/sxn/rules/setup_commands_rule.rbs +60 -0
- data/sig/sxn/rules/template_rule.rbs +44 -0
- data/sig/sxn/rules.rbs +287 -0
- data/sig/sxn/runtime_validations.rbs +16 -0
- data/sig/sxn/security/secure_command_executor.rbs +63 -0
- data/sig/sxn/security/secure_file_copier.rbs +79 -0
- data/sig/sxn/security/secure_path_validator.rbs +30 -0
- data/sig/sxn/security.rbs +128 -0
- data/sig/sxn/templates/errors.rbs +43 -0
- data/sig/sxn/templates/template_engine.rbs +50 -0
- data/sig/sxn/templates/template_processor.rbs +44 -0
- data/sig/sxn/templates/template_security.rbs +62 -0
- data/sig/sxn/templates/template_variables.rbs +103 -0
- data/sig/sxn/templates.rbs +104 -0
- data/sig/sxn/ui/output.rbs +50 -0
- data/sig/sxn/ui/progress_bar.rbs +39 -0
- data/sig/sxn/ui/prompt.rbs +38 -0
- data/sig/sxn/ui/table.rbs +43 -0
- data/sig/sxn/ui.rbs +63 -0
- data/sig/sxn/version.rbs +5 -0
- data/sig/sxn.rbs +29 -0
- 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
|