Class: Rubino::Tools::WriteTool

Inherits:
Base
  • Object
show all
Defined in:
lib/rubino/tools/write_tool.rb

Overview

Writes content to a file, creating parent directories if needed. Overwrites existing files (the LLM is expected to Read first when in doubt). Kept intentionally narrow — no append mode, no partial writes; those belong in ‘edit` / `multi_edit`.

Instance Attribute Summary

Attributes inherited from Base

#cancel_token, #read_tracker, #stream_chunk, #stream_kind

Instance Method Summary collapse

Methods inherited from Base

#cancellation_requested?, #config_key, #emit_chunk, #risky?, #to_tool_definition, workspace_root, workspace_roots

Instance Method Details

#call(arguments) ⇒ Object



37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/rubino/tools/write_tool.rb', line 37

def call(arguments)
  file_path = arguments["file_path"] || arguments[:file_path]
  content   = arguments["content"]   || arguments[:content] || ""

  return "Error: file_path is required" if file_path.nil? || file_path.to_s.empty?

  expanded = expand_workspace_path(file_path)
  # SECRET/credential writes (#446) are no longer HARD-refused here — they
  # are gated UPSTREAM by Security::ApprovalPolicy#decide (→ :ask): an
  # APPROVED write to your .env actually writes, a denied/headless one
  # never reaches #call. The workspace sandbox below is unchanged.
  return workspace_violation_message(file_path) unless within_workspace?(expanded)

  existed = File.exist?(expanded)
  # Read-before-overwrite guard (r5 MF-2, Claude Code's rule): writing
  # over an EXISTING file requires that the model read it this session, so
  # a blind `write` can't silently clobber content the model never saw
  # (the near-data-loss path). NEW files skip the guard. No tracker
  # injected → no guard (single-tool unit tests / one-shot MCP).
  if existed && (guard = overwrite_guard_error(expanded, file_path))
    return guard
  end

  FileUtils.mkdir_p(File.dirname(expanded))
  # Crash-safe write: temp-in-same-dir + fsync + atomic rename, so a
  # SIGINT/SIGTERM/OOM-kill mid-write leaves the ORIGINAL file intact
  # rather than a torn/truncated one (HIGH-1). The bare File.write here
  # could be cut mid-flush, destroying the user's existing content.
  Util::AtomicFile.write_atomic(expanded, content)
  # Refresh-on-own-write so a later edit of this just-written file passes
  # the read-gate (r5 B2) and a re-read sees it as authoritative.
  @read_tracker&.note_write(expanded, content)

  verb  = existed ? "overwrote" : "created"
  bytes = content.to_s.bytesize
  lines = content.to_s.lines.size
  { output: "#{verb} #{file_path} (#{bytes} bytes)",
    metrics: "#{lines} line#{"s" if lines != 1} · #{bytes}B" }
rescue StandardError => e
  "Error writing #{file_path}: #{e.message}"
end

#descriptionObject



16
17
18
19
20
# File 'lib/rubino/tools/write_tool.rb', line 16

def description
  "Write content to a file, overwriting any existing content. " \
    "Creates parent directories if they do not exist. " \
    "Use `edit` or `multi_edit` to modify an existing file in place."
end

#input_schemaObject



22
23
24
25
26
27
28
29
30
31
# File 'lib/rubino/tools/write_tool.rb', line 22

def input_schema
  {
    type: "object",
    properties: {
      file_path: { type: "string", description: "Absolute or relative file path" },
      content: { type: "string", description: "Full file content to write" }
    },
    required: %w[file_path content]
  }
end

#nameObject



12
13
14
# File 'lib/rubino/tools/write_tool.rb', line 12

def name
  "write"
end

#risk_levelObject



33
34
35
# File 'lib/rubino/tools/write_tool.rb', line 33

def risk_level
  :medium
end