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.
30 31 32 33 34 35 36 37 |
# File 'lib/kward/workspace.rb', line 30 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.
40 41 42 |
# File 'lib/kward/workspace.rb', line 40 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.
134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 |
# File 'lib/kward/workspace.rb', line 134 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.
43 44 45 46 47 48 49 50 51 52 |
# File 'lib/kward/workspace.rb', line 43 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.
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 88 89 |
# File 'lib/kward/workspace.rb', line 59 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.
178 179 180 |
# File 'lib/kward/workspace.rb', line 178 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.
157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 |
# File 'lib/kward/workspace.rb', line 157 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.
92 93 94 95 96 97 98 99 100 101 102 103 104 105 |
# File 'lib/kward/workspace.rb', line 92 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.
113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 |
# File 'lib/kward/workspace.rb', line 113 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 |