smart_box 0.1.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fdd527c54db2a72e0d40528a4fb248a712f06a8f28bd11e7b81c995963e1a4e1
4
+ data.tar.gz: b277525806bbccc5ad7a979733d0e9fcaa7ad483f8c8910e5d5105ea78f94712
5
+ SHA512:
6
+ metadata.gz: 61675f220c4dda1cae98b9091201057351b9abd65dfe4d4c4c906aa4be4e926e0091619079034a88fdb8209c17e7fc434014b432bb8397b869fcddd413c40ca2
7
+ data.tar.gz: b14f1b4116d23c01e540eac1275a5048e8decc279034ee33fde8f5b374314402fb2ec0a291a20762cef9e553a1c992425bdb9c19509f2b7bc51ec21371b8c268
data/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # smart_box
2
+
3
+ ## What is smart_box?
4
+
5
+ smart_box is a local reversible sandbox system for Coding Agent task execution.
6
+
7
+ Its core goal: allow Coding Agents (SmartExpert, SmartCoder, etc.) to perform file
8
+ modifications, command execution, dependency installation, experimental fixes, code
9
+ generation, diff viewing, checkpoint rollback, and patch export — without directly
10
+ polluting the real project directory.
11
+
12
+ ## Why smart_box?
13
+
14
+ When a Coding Agent runs autonomously, it may:
15
+
16
+ - Modify source files incorrectly
17
+ - Install conflicting dependencies
18
+ - Remove critical files
19
+ - Produce untested patches
20
+
21
+ smart_box provides a safety net: every operation happens inside an isolated box
22
+ first. The user can inspect, verify, and then explicitly apply changes to the
23
+ real project.
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ gem install smart_box
29
+ ```
30
+
31
+ Or via Bundler:
32
+
33
+ ```ruby
34
+ gem "smart_box"
35
+ ```
36
+
37
+ ## Quick Start
38
+
39
+ ```bash
40
+ # Create a box from current project (copy mode)
41
+ smart_box create --source . --id task-001 --mode copy
42
+
43
+ # Run a command inside the box
44
+ smart_box run --id task-001 -- bundle install
45
+
46
+ # Create a checkpoint
47
+ smart_box checkpoint --id task-001 --name "after bundle install"
48
+
49
+ # View changes
50
+ smart_box diff --id task-001
51
+
52
+ # Export patch
53
+ smart_box export-patch --id task-001 --output fix.patch
54
+
55
+ # Rollback to initial state
56
+ smart_box rollback --id task-001 --checkpoint cp-001 --mode copy
57
+
58
+ # Apply to source project
59
+ smart_box apply --id task-001
60
+
61
+ # Discard the box
62
+ smart_box discard --id task-001
63
+ ```
64
+
65
+ ## Concepts
66
+
67
+ - **Source Project**: The original project directory. smart_box never modifies it directly.
68
+ - **Box**: An isolated execution space for one task or experiment.
69
+ - **Checkpoint**: A saved state inside a box, allowing rollback.
70
+ - **Diff**: Changes between the box's current state and a reference point.
71
+ - **Patch**: A portable changeset exported from a box for manual review and application.
72
+
73
+ ## CLI Usage
74
+
75
+ ```
76
+ smart_box <command> [options]
77
+ ```
78
+
79
+ | Command | Description |
80
+ |---------------|--------------------------------------|
81
+ | create | Create a new box |
82
+ | list | List all boxes |
83
+ | status | Show box status |
84
+ | run | Execute a command inside a box |
85
+ | checkpoint | Create a checkpoint |
86
+ | checkpoints | List checkpoints in a box |
87
+ | rollback | Rollback to a checkpoint |
88
+ | diff | Show diff against checkpoint |
89
+ | export-patch | Export patch to a file |
90
+ | apply | Apply box changes to source project |
91
+ | discard | Discard a box and its workspace |
92
+
93
+ ## Ruby API Usage
94
+
95
+ ```ruby
96
+ require "smart_box"
97
+
98
+ box = SmartBox::Box.create(
99
+ source: ".",
100
+ id: "task-001",
101
+ mode: :copy,
102
+ name: "fix bundler conflict"
103
+ )
104
+
105
+ result = box.run("bundle install")
106
+ box.checkpoint("after bundle install")
107
+ puts box.diff
108
+ box.rollback("cp-001")
109
+ box.export_patch("fix.patch")
110
+ box.apply(dry_run: true)
111
+ ```
112
+
113
+ ## Modes
114
+
115
+ ### copy mode
116
+
117
+ Copies the source project (excluding `.git`, `node_modules`, etc.) into an
118
+ isolated workspace. Simple and works even if the source is not a git repository.
119
+
120
+ ### git-worktree mode
121
+
122
+ Uses `git worktree add` to create an isolated working directory. Faster for
123
+ large projects and natively compatible with git workflows.
124
+
125
+ ## Safety Notes
126
+
127
+ - Dangerous commands (`rm -rf /`, `sudo`, etc.) are blocked by default.
128
+ - All commands are restricted to the box workspace.
129
+ - The source project is never modified without explicit `apply`.
130
+ - All paths use absolute normalization to prevent path traversal.
131
+
132
+ ## Roadmap
133
+
134
+ - [x] copy mode
135
+ - [x] git-worktree mode
136
+ - [ ] Docker / DevContainer mode
137
+ - [ ] Command policy configuration
138
+ - [ ] Network policy
139
+ - [ ] Resource limits
140
+ - [ ] Concurrent boxes
141
+ - [ ] SmartExpert TUI integration
142
+ - [ ] SmartCoder workflow integration
143
+ - [ ] MCP tool wrapper
144
+ - [ ] JSON-RPC server mode
data/bin/smart_box ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/smart_box"
5
+ require_relative "../lib/smart_box/cli"
6
+
7
+ SmartBox::CLI.run(ARGV)
@@ -0,0 +1,381 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+ require_relative "errors"
6
+ require_relative "metadata"
7
+ require_relative "modes/copy_mode"
8
+
9
+ module SmartBox
10
+ class Box
11
+ attr_reader :id, :source_path, :workspace_path, :mode, :metadata
12
+
13
+ SMART_BOX_DIR = ".smart_box"
14
+ BOXES_DIR = File.join(SMART_BOX_DIR, "boxes")
15
+
16
+ def initialize(source_path:, id:, mode:, name: nil)
17
+ @id = id
18
+ @mode = mode.to_s
19
+ @source_path = File.expand_path(source_path)
20
+ @name = name
21
+ @box_dir = File.join(@source_path, BOXES_DIR, @id)
22
+ @workspace_path = File.join(@box_dir, "workspace")
23
+ @metadata_path = File.join(@box_dir, "metadata.yml")
24
+ @metadata = Metadata.new(@metadata_path)
25
+ end
26
+
27
+ # --- Class methods ---
28
+
29
+ def self.create(source:, id:, mode:, name: nil)
30
+ box = new(source_path: source, id: id, mode: mode, name: name)
31
+
32
+ if Dir.exist?(box.send(:box_dir))
33
+ raise BoxAlreadyExistsError, "Box '#{id}' already exists"
34
+ end
35
+
36
+ FileUtils.mkdir_p(box.send(:box_dir))
37
+ FileUtils.mkdir_p(File.join(box.send(:box_dir), "logs"))
38
+ FileUtils.mkdir_p(File.join(box.send(:box_dir), "patches"))
39
+ FileUtils.mkdir_p(File.join(box.send(:box_dir), "checkpoints"))
40
+
41
+ mode_instance = box.send(:mode_instance)
42
+ mode_instance.setup
43
+
44
+ # Capture initial git commit hash
45
+ initial_commit = box.send(:git_latest_commit)
46
+
47
+ box.metadata.id = id
48
+ box.metadata.name = name
49
+ box.metadata.mode = mode.to_s
50
+ box.metadata.source_path = box.source_path
51
+ box.metadata.workspace_path = box.workspace_path
52
+ box.metadata.status = "active"
53
+ box.metadata.created_at = Time.now.utc.iso8601
54
+ box.metadata.updated_at = Time.now.utc.iso8601
55
+ box.metadata.base = {
56
+ "type" => mode.to_s,
57
+ "source_git_commit" => box.send(:source_git_commit),
58
+ "source_git_branch" => box.send(:source_git_branch)
59
+ }
60
+ box.metadata.add_checkpoint(
61
+ id: "cp-001",
62
+ name: "initial",
63
+ git_commit: initial_commit,
64
+ created_at: Time.now.utc.iso8601
65
+ )
66
+ box.metadata.stats = {
67
+ "commands_count" => 0,
68
+ "changed_files_count" => 0
69
+ }
70
+ box.metadata.save!
71
+
72
+ box
73
+ end
74
+
75
+ def self.load(source:, id:)
76
+ box = new(source_path: source, id: id, mode: nil)
77
+
78
+ unless Dir.exist?(box.send(:box_dir))
79
+ raise BoxNotFoundError, "Box '#{id}' not found"
80
+ end
81
+
82
+ box.metadata.load!
83
+
84
+ unless box.metadata.id
85
+ raise BoxNotFoundError, "Box '#{id}' metadata is missing 'id' field"
86
+ end
87
+
88
+ box
89
+ end
90
+
91
+ def self.list(source:)
92
+ boxes_path = File.join(File.expand_path(source), BOXES_DIR)
93
+ return [] unless Dir.exist?(boxes_path)
94
+
95
+ Dir.each_child(boxes_path).filter_map do |entry|
96
+ box_path = File.join(boxes_path, entry)
97
+ next unless Dir.exist?(box_path)
98
+
99
+ metadata_path = File.join(box_path, "metadata.yml")
100
+ next unless File.exist?(metadata_path)
101
+
102
+ meta = YAML.safe_load_file(metadata_path, permitted_classes: [Time])
103
+ next unless meta.is_a?(Hash)
104
+
105
+ {
106
+ "id" => meta["id"] || entry,
107
+ "mode" => meta["mode"] || "unknown",
108
+ "status" => meta["status"] || "unknown",
109
+ "updated_at" => meta["updated_at"] || "unknown"
110
+ }
111
+ end
112
+ end
113
+
114
+ # --- Instance methods ---
115
+
116
+ def status_summary
117
+ {
118
+ "id" => @id,
119
+ "mode" => @mode || @metadata.mode,
120
+ "status" => @metadata.status,
121
+ "workspace" => @workspace_path,
122
+ "changed_files" => git_status_short,
123
+ "checkpoints" => @metadata.checkpoints.map { |cp| { cp["id"] => cp["name"] } }
124
+ }
125
+ end
126
+
127
+ def git_status_short
128
+ return [] unless Dir.exist?(@workspace_path)
129
+
130
+ Dir.chdir(@workspace_path) do
131
+ out = `git status --porcelain 2>/dev/null`
132
+ return [] unless $?.success?
133
+
134
+ out.lines.map(&:chomp).reject(&:empty?)
135
+ end
136
+ end
137
+
138
+ def run(command, env: {}, timeout: nil, allow_dangerous: false)
139
+ runner.run(command, env: env, timeout: timeout, allow_dangerous: allow_dangerous).tap do
140
+ # Update metadata stats
141
+ @metadata.load!
142
+ stats = @metadata.stats
143
+ stats["commands_count"] = (stats["commands_count"] || 0) + 1
144
+ @metadata.updated_at = Time.now.utc.iso8601
145
+ @metadata.save!
146
+ end
147
+ end
148
+
149
+ def checkpoint(name)
150
+ raise Error, "Workspace does not exist" unless Dir.exist?(@workspace_path)
151
+
152
+ Dir.chdir(@workspace_path) do
153
+ system("git", "add", "-A", out: File::NULL, err: File::NULL)
154
+ system("git", "commit", "--allow-empty", "-m", name, out: File::NULL, err: File::NULL)
155
+ end
156
+
157
+ commit = git_latest_commit
158
+ cp_id = "cp-#{format('%03d', (@metadata.checkpoints.size + 1))}"
159
+
160
+ @metadata.load!
161
+ @metadata.add_checkpoint(
162
+ id: cp_id,
163
+ name: name,
164
+ git_commit: commit,
165
+ created_at: Time.now.utc.iso8601
166
+ )
167
+ @metadata.updated_at = Time.now.utc.iso8601
168
+ @metadata.save!
169
+
170
+ { id: cp_id, name: name, commit: commit }
171
+ end
172
+
173
+ def checkpoints
174
+ @metadata.load!
175
+ @metadata.checkpoints.map do |cp|
176
+ { "id" => cp["id"], "name" => cp["name"] }
177
+ end
178
+ end
179
+
180
+ def rollback(checkpoint_id)
181
+ cp = @metadata.checkpoints.detect { |c| c["id"] == checkpoint_id }
182
+ raise CheckpointNotFoundError, "Checkpoint '#{checkpoint_id}' not found" unless cp
183
+
184
+ Dir.chdir(@workspace_path) do
185
+ system("git", "reset", "--hard", cp["git_commit"], out: File::NULL, err: File::NULL)
186
+ system("git", "clean", "-fd", out: File::NULL, err: File::NULL)
187
+ end
188
+
189
+ @metadata.load!
190
+ @metadata.updated_at = Time.now.utc.iso8601
191
+ @metadata.save!
192
+
193
+ { box_id: @id, checkpoint_id: checkpoint_id }
194
+ end
195
+
196
+ def diff(from: nil, to: nil)
197
+ raise Error, "Workspace does not exist" unless Dir.exist?(@workspace_path)
198
+
199
+ Dir.chdir(@workspace_path) do
200
+ # Stage everything so untracked files appear in diff
201
+ system("git", "add", "-A", out: File::NULL, err: File::NULL)
202
+
203
+ from_commit = if from
204
+ resolve_checkpoint_commit(from)
205
+ else
206
+ init = @metadata.checkpoints.first
207
+ init ? init["git_commit"] : "HEAD~1"
208
+ end
209
+
210
+ to_commit = to ? resolve_checkpoint_commit(to) : nil
211
+
212
+ if to_commit
213
+ `git diff #{from_commit} #{to_commit} 2>/dev/null`
214
+ else
215
+ `git diff --cached #{from_commit} 2>/dev/null`
216
+ end
217
+ end
218
+ end
219
+
220
+ def export_patch(output:, from: nil, to: nil)
221
+ output_path = if output.start_with?("/")
222
+ output
223
+ else
224
+ File.join(Dir.pwd, output)
225
+ end
226
+
227
+ patch_content = diff(from: from, to: to)
228
+ File.write(output_path, patch_content)
229
+
230
+ { output: output_path, size: patch_content.bytesize }
231
+ end
232
+
233
+ def apply(dry_run: false, force: false)
234
+ raise Error, "Workspace does not exist" unless Dir.exist?(@workspace_path)
235
+
236
+ patch_content = diff(from: @metadata.checkpoints.first&.dig("git_commit"))
237
+
238
+ if dry_run
239
+ return { dry_run: true, patch_size: patch_content.bytesize }
240
+ end
241
+
242
+ # Check if source is a git repo and if it's clean
243
+ if Dir.exist?(File.join(@source_path, ".git"))
244
+ unless force
245
+ check_source_clean!
246
+ end
247
+
248
+ # Create backup patch of current source state
249
+ backup_patch_path = File.join(@box_dir, "patches", "backup.patch")
250
+ FileUtils.mkdir_p(File.join(@box_dir, "patches"))
251
+
252
+ Dir.chdir(@source_path) do
253
+ backup = `git diff 2>/dev/null`
254
+ File.write(backup_patch_path, backup) unless backup.empty?
255
+ end
256
+
257
+ # Apply the patch
258
+ Dir.chdir(@source_path) do
259
+ IO.popen(["git", "apply", "-v"], "w") do |io|
260
+ io.write(patch_content)
261
+ end
262
+
263
+ unless $?.success?
264
+ raise PatchApplyError, "Failed to apply patch. The source project may have conflicts."
265
+ end
266
+
267
+ # Show what changed
268
+ result_diff = `git diff 2>/dev/null`
269
+ result_diff
270
+ end
271
+ else
272
+ # Non-git source: apply patch with patch command
273
+ backup_patch_path = File.join(@box_dir, "patches", "backup.diff")
274
+ FileUtils.mkdir_p(File.join(@box_dir, "patches"))
275
+ File.write(backup_patch_path, "backup not available for non-git source")
276
+
277
+ Dir.chdir(@source_path) do
278
+ IO.popen(["patch", "-p1", "-N", "-r", "/dev/null"], "w") do |io|
279
+ io.write(patch_content)
280
+ end
281
+ end
282
+
283
+ "Patch applied to non-git source at #{@source_path}"
284
+ end
285
+ end
286
+
287
+ def discard
288
+ raise Error, "Box directory does not exist" unless Dir.exist?(@box_dir)
289
+
290
+ # For git-worktree mode, remove the worktree first
291
+ if %w[git-worktree git_worktree].include?(@metadata.mode)
292
+ mode_instance.teardown
293
+ end
294
+
295
+ FileUtils.rm_rf(@box_dir)
296
+ @metadata.status = "discarded"
297
+ { id: @id, status: "discarded" }
298
+ end
299
+
300
+ def source_clean?
301
+ return true unless Dir.exist?(File.join(@source_path, ".git"))
302
+ Dir.chdir(@source_path) { `git status --porcelain 2>/dev/null`.strip.empty? }
303
+ end
304
+
305
+ private
306
+
307
+ attr_reader :box_dir, :metadata_path
308
+
309
+ def runner
310
+ @runner ||= Runner.new(
311
+ box_id: @id,
312
+ workspace_path: @workspace_path,
313
+ logs_dir: File.join(@box_dir, "logs")
314
+ )
315
+ end
316
+
317
+ def mode_instance
318
+ current_mode = @mode.to_s.empty? ? @metadata.mode.to_s : @mode.to_s
319
+
320
+ case current_mode
321
+ when "copy"
322
+ Modes::CopyMode.new(
323
+ source_path: @source_path,
324
+ workspace_path: @workspace_path
325
+ )
326
+ when "git-worktree", "git_worktree"
327
+ Modes::GitWorktreeMode.new(
328
+ source_path: @source_path, workspace_path: @workspace_path, box_id: @id
329
+ )
330
+ else
331
+ raise InvalidModeError, "Unknown mode: #{current_mode}"
332
+ end
333
+ end
334
+
335
+ def git_latest_commit
336
+ # In git-worktree mode the workspace's `.git` is a FILE (a gitdir
337
+ # pointer), not a directory — so Dir.exist? is false and this would
338
+ # wrongly return "none". File.exist? is true for both files and dirs.
339
+ return "none" unless File.exist?(File.join(@workspace_path, ".git"))
340
+
341
+ Dir.chdir(@workspace_path) do
342
+ `git rev-parse HEAD 2>/dev/null`.strip
343
+ end
344
+ end
345
+
346
+ def source_git_commit
347
+ git_dir = File.join(@source_path, ".git")
348
+ return "none" unless Dir.exist?(git_dir)
349
+
350
+ Dir.chdir(@source_path) do
351
+ `git rev-parse HEAD 2>/dev/null`.strip
352
+ end
353
+ end
354
+
355
+ def source_git_branch
356
+ git_dir = File.join(@source_path, ".git")
357
+ return "none" unless Dir.exist?(git_dir)
358
+
359
+ Dir.chdir(@source_path) do
360
+ `git rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
361
+ end
362
+ end
363
+
364
+ def resolve_checkpoint_commit(checkpoint_id)
365
+ cp = @metadata.checkpoints.detect { |c| c["id"] == checkpoint_id }
366
+ if cp
367
+ cp["git_commit"]
368
+ else
369
+ checkpoint_id
370
+ end
371
+ end
372
+
373
+ def check_source_clean!
374
+ unless source_clean?
375
+ raise DirtySourceError,
376
+ "Source project has uncommitted changes.\n" \
377
+ "Use --force to apply anyway."
378
+ end
379
+ end
380
+ end
381
+ end