Skip to content
Kward Search API index

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 =
128 * 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

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.



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/kward/workspace.rb', line 119

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)

  large_file_outline_response(path, content, offset: offset, limit: limit) || 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.



173
174
175
# File 'lib/kward/workspace.rb', line 173

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.



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/kward/workspace.rb', line 142

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

#summarize_file_structure(path) ⇒ Object

Returns a compact outline of recognizable source-code declarations.



73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/kward/workspace.rb', line 73

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)

  lines = content.split("\n", -1)
  outline = source_outline(lines)
  return "No recognizable source structure found in #{path}." if outline.empty?

  (["# File structure: #{path}", "- Lines: #{lines.length}", "- Bytes: #{content.bytesize}", "", "## Outline"] + outline).join("\n")
rescue SecurityError, Errno::ENOENT => 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.



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/kward/workspace.rb', line 98

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