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 =
128 * 1024
- MAX_EDIT_DIFF_BYTES =
8 * 1024
- DEFAULT_COMMAND_TIMEOUT_SECONDS =
30- EXPECTED_FILE_ERRORS =
[SecurityError, Errno::ENOENT, Errno::EACCES, Errno::EPERM, Errno::EISDIR, Errno::ENOTDIR].freeze
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, mode: nil, max_bytes: 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.
-
#summarize_file_structure(path) ⇒ Object
Returns a compact outline of recognizable source-code declarations.
-
#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.
132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 |
# File 'lib/kward/workspace.rb', line 132 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 *EXPECTED_FILE_ERRORS => 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 *EXPECTED_FILE_ERRORS => e "Error: #{e.}" end |
#read_file(path, offset: nil, limit: nil, mode: nil, max_bytes: 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 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
# File 'lib/kward/workspace.rb', line 57 def read_file(path, offset: nil, limit: nil, mode: nil, max_bytes: 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_mode = normalize_read_mode(mode) return read_mode if read_mode.is_a?(String) output_budget = read_output_budget(max_bytes) return output_budget if output_budget.is_a?(String) case read_mode when :outline file_structure_summary(path, content) when :preview read_file_slice(content, offset: offset, limit: limit || 120, max_bytes: output_budget) when :range read_file_slice(content, offset: offset, limit: limit, max_bytes: output_budget) when :full read_file_slice(content, offset: offset, limit: limit, max_bytes: output_budget) else large_file_outline_response(path, content, offset: offset, limit: limit) || read_file_slice(content, offset: offset, limit: limit, max_bytes: output_budget) end rescue *EXPECTED_FILE_ERRORS => e "Error: #{e.}" end |
#resolved_path(path) ⇒ Object
Resolves a path with the same guardrails used by file tools.
176 177 178 |
# File 'lib/kward/workspace.rb', line 176 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.
155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 |
# File 'lib/kward/workspace.rb', line 155 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! result = LocalCommandRunner.new(timeout_seconds: timeout_seconds, max_output_bytes: @max_command_output_bytes).run(command, cwd: @root.to_s, cancellation: cancellation) return "Error: command timed out after #{timeout_seconds} seconds" if result.timed_out output = +"Exit status: #{result.exit_status}\n" output << "\nSTDOUT:\n#{result.stdout}" unless result.stdout.empty? output << "\nSTDERR:\n#{result.stderr}" unless result.stderr.empty? output << "\n... truncated to #{@max_command_output_bytes} bytes" if result.truncated truncate_output(output) rescue Errno::ENOENT, ArgumentError => e "Error: #{e.}" end |
#summarize_file_structure(path) ⇒ Object
Returns a compact outline of recognizable source-code declarations.
90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
# File 'lib/kward/workspace.rb', line 90 def summarize_file_structure(path) 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) file_structure_summary(path, content) rescue *EXPECTED_FILE_ERRORS => 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.
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 |
# File 'lib/kward/workspace.rb', line 111 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 *EXPECTED_FILE_ERRORS => e "Error: #{e.}" end |