Class: Kward::Workspace
- Inherits:
-
Object
- Object
- Kward::Workspace
- Defined in:
- lib/kward/workspace.rb
Overview
Filesystem and shell-command boundary for workspace tools.
Workspace is deliberately low-level: it validates paths, enforces output
limits, applies exact edits, writes files, and runs shell commands from one
root directory. It should not know about model prompts, sessions, telemetry,
or UI confirmation. Tool wrappers and frontends provide those policies.
Guardrails are enabled by default and require all file paths to resolve under
root. RPC may report when guardrails are disabled, but callers should avoid
bypassing this class for local filesystem mutation so read-before-write and
path safety remain consistent.
Constant Summary collapse
- MAX_FILE_BYTES =
256 * 1024
- MAX_READ_OUTPUT_BYTES =
50 * 1024
- MAX_READ_OUTPUT_LINES =
2_000- MAX_COMMAND_OUTPUT_BYTES =
20 * 1024
- MAX_EDIT_DIFF_BYTES =
8 * 1024
- DEFAULT_COMMAND_TIMEOUT_SECONDS =
30
Instance Attribute Summary collapse
-
#root ⇒ Pathname
readonly
Canonical workspace root used as the base for file and shell tools.
Instance Method Summary collapse
-
#edit_file(path, edits, read_paths:) ⇒ Object
Applies exact non-overlapping replacements to a previously read file.
-
#initialize(root: Dir.pwd, max_file_bytes: MAX_FILE_BYTES, max_read_output_bytes: MAX_READ_OUTPUT_BYTES, max_read_output_lines: MAX_READ_OUTPUT_LINES, max_command_output_bytes: MAX_COMMAND_OUTPUT_BYTES, guardrails: true) ⇒ Workspace
constructor
Creates an object for workspace filesystem and shell operations.
-
#list_directory(path) ⇒ Object
Lists immediate directory children after resolving
paththrough workspace guardrails. -
#read_file(path, offset: nil, limit: nil) ⇒ Object
Reads a bounded text slice from a workspace file.
-
#resolved_path(path) ⇒ Object
Resolves a path with the same guardrails used by file tools.
-
#run_shell_command(command, timeout_seconds: DEFAULT_COMMAND_TIMEOUT_SECONDS, cancellation: nil) ⇒ Object
Runs a shell command from the workspace root with timeout, cancellation, and bounded combined output.
-
#write_file(path, content, read_paths:) ⇒ Object
Writes complete file content after enforcing read-before-write for existing files.
Constructor Details
#initialize(root: Dir.pwd, max_file_bytes: MAX_FILE_BYTES, max_read_output_bytes: MAX_READ_OUTPUT_BYTES, max_read_output_lines: MAX_READ_OUTPUT_LINES, max_command_output_bytes: MAX_COMMAND_OUTPUT_BYTES, guardrails: true) ⇒ Workspace
Creates an object for workspace filesystem and shell operations.
28 29 30 31 32 33 34 35 |
# File 'lib/kward/workspace.rb', line 28 def initialize(root: Dir.pwd, max_file_bytes: MAX_FILE_BYTES, max_read_output_bytes: MAX_READ_OUTPUT_BYTES, max_read_output_lines: MAX_READ_OUTPUT_LINES, max_command_output_bytes: MAX_COMMAND_OUTPUT_BYTES, guardrails: true) @root = Pathname.new(root).realpath @guardrails = guardrails @max_file_bytes = max_file_bytes @max_read_output_bytes = max_read_output_bytes @max_read_output_lines = max_read_output_lines @max_command_output_bytes = max_command_output_bytes end |
Instance Attribute Details
#root ⇒ Pathname (readonly)
Returns canonical workspace root used as the base for file and shell tools.
38 39 40 |
# File 'lib/kward/workspace.rb', line 38 def root @root end |
Instance Method Details
#edit_file(path, edits, read_paths:) ⇒ Object
Applies exact non-overlapping replacements to a previously read file.
Each old_text must match exactly once. This favors predictable model edits
over fuzzy patching and returns readable error strings when more context is
needed.
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 |
# File 'lib/kward/workspace.rb', line 99 def edit_file(path, edits, read_paths:) resolved = workspace_path(path) return "Error: not a file: #{path}" unless File.file?(resolved) return "Error: existing file must be read before editing: #{path}" unless read_paths.include?(resolved) size = File.size(resolved) return "Error: file too large: #{path} is #{size} bytes; limit is #{@max_file_bytes} bytes" if size > @max_file_bytes content = File.read(resolved) result = apply_edits(path, content, edits) return result[:error] if result[:error] File.write(resolved, result[:content]) "Edited #{path}: replaced #{result[:count]} block(s)\n#{truncated_diff(path, content, result[:content])}" rescue SecurityError, Errno::ENOENT => e "Error: #{e.}" end |
#list_directory(path) ⇒ Object
Lists immediate directory children after resolving path through workspace guardrails.
41 42 43 44 45 46 47 48 49 50 |
# File 'lib/kward/workspace.rb', line 41 def list_directory(path) resolved = workspace_path(path) return "Error: not a directory: #{path}" unless File.directory?(resolved) Dir.children(resolved).sort.map do |entry| File.directory?(File.join(resolved, entry)) ? "#{entry}/" : entry end.join("\n") rescue SecurityError, Errno::ENOENT => e "Error: #{e.}" end |
#read_file(path, offset: nil, limit: nil) ⇒ Object
Reads a bounded text slice from a workspace file.
The returned string is user/model-facing and includes continuation notices
when output is truncated. Errors are returned as "Error: ..." strings so
tool calls can be persisted in the conversation without raising.
57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
# File 'lib/kward/workspace.rb', line 57 def read_file(path, offset: nil, limit: nil) resolved = workspace_path(path) return "Error: not a file: #{path}" unless File.file?(resolved) size = File.size(resolved) return "Error: file too large: #{path} is #{size} bytes; limit is #{@max_file_bytes} bytes" if size > @max_file_bytes content = File.read(resolved) return "Error: not a text file: #{path}" if binary_content?(content) read_file_slice(content, offset: offset, limit: limit) rescue SecurityError, Errno::ENOENT => e "Error: #{e.}" end |
#resolved_path(path) ⇒ Object
Resolves a path with the same guardrails used by file tools.
153 154 155 |
# File 'lib/kward/workspace.rb', line 153 def resolved_path(path) workspace_path(path) end |
#run_shell_command(command, timeout_seconds: DEFAULT_COMMAND_TIMEOUT_SECONDS, cancellation: nil) ⇒ Object
Runs a shell command from the workspace root with timeout, cancellation, and bounded combined output.
This method intentionally does not ask for confirmation; CLI/RPC policy must decide whether a command is allowed before reaching this boundary.
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 |
# File 'lib/kward/workspace.rb', line 122 def run_shell_command(command, timeout_seconds: DEFAULT_COMMAND_TIMEOUT_SECONDS, cancellation: nil) command = command.to_s.strip return "Error: command is required" if command.empty? timeout_seconds = timeout_seconds.to_i timeout_seconds = DEFAULT_COMMAND_TIMEOUT_SECONDS if timeout_seconds <= 0 cancellation&.raise_if_cancelled! Open3.popen3(command, chdir: @root.to_s) do |stdin, stdout, stderr, wait_thread| stdin.close stdout_reader = Thread.new { stdout.read } stderr_reader = Thread.new { stderr.read } cancellation&.on_cancel { terminate_process(wait_thread.pid) } status = wait_for_process(wait_thread, timeout_seconds, cancellation) output = +"Exit status: #{status.exitstatus}\n" output << "\nSTDOUT:\n#{stdout_reader.value}" unless stdout_reader.value.empty? output << "\nSTDERR:\n#{stderr_reader.value}" unless stderr_reader.value.empty? truncate_output(output) rescue Timeout::Error terminate_process(wait_thread.pid) "Error: command timed out after #{timeout_seconds} seconds" ensure stdout_reader&.kill if stdout_reader&.alive? stderr_reader&.kill if stderr_reader&.alive? end rescue Errno::ENOENT, ArgumentError => e "Error: #{e.}" end |
#write_file(path, content, read_paths:) ⇒ Object
Writes complete file content after enforcing read-before-write for existing files.
read_paths must contain resolved paths previously observed by
ReadFile; this keeps tool-driven edits explicit and prevents overwriting
unseen user files.
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 |
# File 'lib/kward/workspace.rb', line 78 def write_file(path, content, read_paths:) resolved = workspace_write_path(path) if File.exist?(resolved) && !read_paths.include?(resolved) return "Error: existing file must be read before writing: #{path}" end old_content = File.exist?(resolved) ? File.read(resolved) : nil File.write(resolved, content) output = "Wrote #{content.bytesize} bytes to #{path}" output << "\n#{truncated_diff(path, old_content, content)}" if old_content && old_content != content output rescue SecurityError, Errno::ENOENT => e "Error: #{e.}" end |