Class: Kward::Workspace

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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

#rootPathname (readonly)

Returns canonical workspace root used as the base for file and shell tools.

Returns:

  • (Pathname)

    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.message}"
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.message}"
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.message}"
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.message}"
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.message}"
end