125 lines
3.9 KiB
Ruby
125 lines
3.9 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "pathname"
|
|
|
|
module Opencode
|
|
# One file living inside an Opencode::Sandbox.
|
|
#
|
|
# Carries the safety predicate inline (#safe?) so the orchestrator
|
|
# doesn't have to know what "safe" means — symlink, realpath inside
|
|
# the sandbox, size cap. Carries the default identity conversion to
|
|
# Artifact (#as_artifact) so non-transform code can attach a sandbox
|
|
# file as-is without re-implementing the marcel + StringIO ceremony.
|
|
#
|
|
# mtime-cutoff freshness lives on Opencode::Sandbox#files(after:),
|
|
# not here — the file doesn't know which turn opened "after." That's
|
|
# a property of the scan, not a property of the file.
|
|
class SandboxFile
|
|
UnsafeFileError = Class.new(Opencode::Error)
|
|
|
|
attr_reader :path, :sandbox_prefix
|
|
|
|
def initialize(path:, sandbox_prefix:, max_bytes:)
|
|
@path = path
|
|
@sandbox_prefix = sandbox_prefix
|
|
@max_bytes = max_bytes
|
|
end
|
|
|
|
def basename
|
|
File.basename(path)
|
|
end
|
|
|
|
def size
|
|
File.size(path)
|
|
end
|
|
|
|
def mtime
|
|
File.mtime(path)
|
|
end
|
|
|
|
def content
|
|
with_safe_file do |file|
|
|
content = file.read(@max_bytes + 1) || "".b
|
|
if content.bytesize > @max_bytes
|
|
raise UnsafeFileError, "Sandbox file exceeds size limit while reading: #{basename}"
|
|
end
|
|
content
|
|
end
|
|
end
|
|
|
|
def content_type
|
|
Marcel::MimeType.for(name: basename)
|
|
end
|
|
|
|
# Defense-in-depth on individual file paths the scan yielded:
|
|
#
|
|
# - Reject symlinks (no follow-the-link escape).
|
|
# - The resolved realpath of the path must lie inside the sandbox
|
|
# with a separator-terminated prefix so /sandbox-1 doesn't false-
|
|
# positive on /sandbox-10/foo.
|
|
# - Reject anything over the size cap (default
|
|
# Opencode::ResponseParser::MAX_ARTIFACT_SIZE = 10 MB).
|
|
#
|
|
# Revalidates the opened file descriptor so a path swap between the
|
|
# sandbox scan and the read cannot redirect content outside the sandbox.
|
|
def safe?
|
|
with_safe_file { true }
|
|
rescue UnsafeFileError
|
|
false
|
|
end
|
|
|
|
# Identity conversion: this sandbox file → an Artifact carrying the
|
|
# file's own bytes. Used by the substrate's default (non-transform)
|
|
# path, where the agent writes document bytes directly to the
|
|
# sandbox and the host serves them back unchanged.
|
|
def as_artifact
|
|
Artifact.new(
|
|
filename: basename,
|
|
content: content,
|
|
content_type: content_type
|
|
)
|
|
end
|
|
|
|
private
|
|
|
|
def with_safe_file
|
|
before = File.lstat(path)
|
|
unless before.file? && !before.symlink? && before.nlink == 1
|
|
raise UnsafeFileError, "Unsafe sandbox file: #{basename}"
|
|
end
|
|
|
|
resolved = Pathname.new(path).realpath.to_s
|
|
unless resolved.start_with?(sandbox_prefix)
|
|
raise UnsafeFileError, "Sandbox file escapes its root: #{basename}"
|
|
end
|
|
|
|
flags = safe_open_flags
|
|
File.open(path, flags, encoding: Encoding::BINARY) do |file|
|
|
opened = file.stat
|
|
unless opened.file? && opened.nlink == 1 && opened.dev == before.dev && opened.ino == before.ino
|
|
raise UnsafeFileError, "Sandbox file changed while opening: #{basename}"
|
|
end
|
|
if opened.size > @max_bytes
|
|
raise UnsafeFileError, "Sandbox file exceeds size limit: #{basename}"
|
|
end
|
|
|
|
yield file
|
|
end
|
|
rescue SystemCallError => e
|
|
raise UnsafeFileError, "Unsafe sandbox file #{basename}: #{e.message}"
|
|
end
|
|
|
|
def safe_open_flags
|
|
required = %i[NONBLOCK NOFOLLOW]
|
|
missing = required.reject { |name| File.const_defined?(name) }
|
|
unless missing.empty?
|
|
raise UnsafeFileError, "Platform cannot safely open sandbox files: missing #{missing.join(", ")}"
|
|
end
|
|
|
|
flags = File::RDONLY | File::NONBLOCK | File::NOFOLLOW
|
|
flags |= File::BINARY if File.const_defined?(:BINARY)
|
|
flags
|
|
end
|
|
end
|
|
end
|