Harden alpha8 publication safety
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
require "open3"
|
||||
|
||||
# Smoke test: every constant the gem promises is defined and points at
|
||||
# the right kind of object. If require "opencode-rails" loads cleanly,
|
||||
@@ -46,8 +47,9 @@ class Opencode::LoadingTest < Minitest::Test
|
||||
|
||||
def test_session_constant_points_at_this_gem
|
||||
location = Opencode::Session.instance_method(:initialize).source_location.first
|
||||
assert_match GEM_SOURCE_PATTERN.call("opencode-rails", "session"), location,
|
||||
"Expected Opencode::Session to be loaded from opencode-rails, got: #{location}"
|
||||
expected = File.expand_path("../../lib/opencode/session.rb", __dir__)
|
||||
assert_equal expected, File.expand_path(location),
|
||||
"Expected Opencode::Session to be loaded from this opencode-rails checkout"
|
||||
end
|
||||
|
||||
def test_client_constant_points_at_opencode_ruby
|
||||
@@ -71,4 +73,24 @@ class Opencode::LoadingTest < Minitest::Test
|
||||
refute Opencode.const_defined?(:Rails),
|
||||
"Opencode::Rails must not be defined — it would shadow ::Rails inside the Opencode namespace"
|
||||
end
|
||||
|
||||
def test_umbrella_require_loads_runtime_standard_libraries
|
||||
root = File.expand_path("../..", __dir__)
|
||||
script = <<~'RUBY'
|
||||
require "opencode-rails"
|
||||
abort "StringIO missing" unless defined?(StringIO)
|
||||
abort "Marcel missing" unless defined?(Marcel::MimeType)
|
||||
abort "FileUtils missing" unless defined?(FileUtils)
|
||||
abort "Tempfile missing" unless defined?(Tempfile)
|
||||
RUBY
|
||||
|
||||
_stdout, stderr, status = Open3.capture3(
|
||||
Gem.ruby,
|
||||
"-I#{File.join(root, "lib")}",
|
||||
"-e",
|
||||
script
|
||||
)
|
||||
|
||||
assert status.success?, stderr
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,6 +7,23 @@ require "test_helper"
|
||||
# coverage — including ActiveStorage attachment, Transform application,
|
||||
# error reporting via Opencode::ErrorReporter — lives in the host app.
|
||||
class Opencode::MessageArtifactsTest < Minitest::Test
|
||||
Message = Struct.new(:id, keyword_init: true)
|
||||
Sandbox = Struct.new(:entries, keyword_init: true) do
|
||||
def exists? = true
|
||||
def files(after: nil) = entries
|
||||
end
|
||||
SandboxEntry = Struct.new(:basename, :attached, keyword_init: true) do
|
||||
def as_artifact
|
||||
self.attached = true
|
||||
raise "destination must not use the identity attachment path"
|
||||
end
|
||||
end
|
||||
|
||||
class Transform < Opencode::Transform
|
||||
def source_filename = "source.json"
|
||||
def destination_filename = "rendered.html"
|
||||
end
|
||||
|
||||
def test_initialize_takes_message_feature_and_optional_transforms
|
||||
params = Opencode::MessageArtifacts.instance_method(:initialize).parameters
|
||||
by_kind = params.group_by(&:first).transform_values { |list| list.map(&:last) }
|
||||
@@ -23,4 +40,17 @@ class Opencode::MessageArtifactsTest < Minitest::Test
|
||||
assert_equal [ :attach_from ], Opencode::MessageArtifacts.instance_methods(false),
|
||||
"MessageArtifacts's only public verb is #attach_from"
|
||||
end
|
||||
|
||||
def test_agent_authored_transform_destination_never_uses_default_attachment
|
||||
entry = SandboxEntry.new(basename: "rendered.html", attached: false)
|
||||
artifacts = Opencode::MessageArtifacts.new(
|
||||
message: Message.new(id: 1),
|
||||
feature: "test",
|
||||
transforms: [ Transform.new ]
|
||||
)
|
||||
|
||||
artifacts.attach_from(sandbox: Sandbox.new(entries: [ entry ]))
|
||||
|
||||
refute entry.attached
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,6 +11,7 @@ require "marcel" # SandboxFile#content_type uses Marcel::MimeType.for
|
||||
class Opencode::SandboxFileTest < Minitest::Test
|
||||
def setup
|
||||
@tmpdir = Dir.mktmpdir("opencode-rails-sandbox-file-test-")
|
||||
@outside_dir = Dir.mktmpdir("opencode-rails-sandbox-file-outside-")
|
||||
@path = File.join(@tmpdir, "notes.md")
|
||||
File.write(@path, "# hello\nworld\n")
|
||||
# SandboxFile uses `start_with?` against this prefix to detect path
|
||||
@@ -21,6 +22,7 @@ class Opencode::SandboxFileTest < Minitest::Test
|
||||
|
||||
def teardown
|
||||
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
|
||||
FileUtils.remove_entry(@outside_dir) if @outside_dir && File.exist?(@outside_dir)
|
||||
end
|
||||
|
||||
def test_basic_readers
|
||||
@@ -59,4 +61,49 @@ class Opencode::SandboxFileTest < Minitest::Test
|
||||
assert_equal "notes.md", artifact.filename
|
||||
assert_equal "# hello\nworld\n", artifact.content
|
||||
end
|
||||
|
||||
def test_content_rejects_a_file_replaced_by_an_outside_symlink
|
||||
file = Opencode::SandboxFile.new(
|
||||
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
|
||||
)
|
||||
outside = File.join(@outside_dir, "private.txt")
|
||||
File.write(outside, "private")
|
||||
assert file.safe?
|
||||
|
||||
File.unlink(@path)
|
||||
File.symlink(outside, @path)
|
||||
|
||||
assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content }
|
||||
end
|
||||
|
||||
def test_content_rejects_an_inode_that_grows_past_the_cap_while_reading
|
||||
file = Opencode::SandboxFile.new(
|
||||
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 20
|
||||
)
|
||||
real_open = File.method(:open)
|
||||
grow_on_read = lambda do |path, *args, **kwargs, &block|
|
||||
real_open.call(path, *args, **kwargs) do |opened|
|
||||
proxy = Object.new
|
||||
proxy.define_singleton_method(:stat) { opened.stat }
|
||||
proxy.define_singleton_method(:read) do |length = nil|
|
||||
real_open.call(path, "ab") { |writer| writer.write("x" * 100) }
|
||||
opened.read(length)
|
||||
end
|
||||
block.call(proxy)
|
||||
end
|
||||
end
|
||||
|
||||
File.stub(:open, grow_on_read) do
|
||||
assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content }
|
||||
end
|
||||
end
|
||||
|
||||
def test_content_returns_binary_empty_string_for_a_zero_byte_file
|
||||
File.write(@path, "")
|
||||
file = Opencode::SandboxFile.new(
|
||||
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 20
|
||||
)
|
||||
|
||||
assert_equal "".b, file.content
|
||||
end
|
||||
end
|
||||
|
||||
@@ -29,7 +29,7 @@ class Opencode::TransformTest < Minitest::Test
|
||||
end
|
||||
|
||||
# A trivial concrete subclass exercises the defaults that DO exist
|
||||
# (#applies_to?, #trusted?, #owned_filenames all delegate to the
|
||||
# (#applies_to? and #owned_filenames delegate to the
|
||||
# two abstract filename methods).
|
||||
class FakeTransform < Opencode::Transform
|
||||
def source_filename = "agent-output.json"
|
||||
@@ -48,13 +48,11 @@ class Opencode::TransformTest < Minitest::Test
|
||||
refute transform.applies_to?(other)
|
||||
end
|
||||
|
||||
def test_trusted_matches_destination_filename_by_default
|
||||
def test_trusted_fails_closed_by_default
|
||||
transform = FakeTransform.new
|
||||
trusted = Attachment.new(filename: "rendered.html")
|
||||
untrusted = Attachment.new(filename: "agent-output.json")
|
||||
destination = Attachment.new(filename: "rendered.html")
|
||||
|
||||
assert transform.trusted?(trusted)
|
||||
refute transform.trusted?(untrusted)
|
||||
refute transform.trusted?(destination)
|
||||
end
|
||||
|
||||
def test_owned_filenames_is_source_and_destination
|
||||
|
||||
@@ -17,6 +17,13 @@ class Opencode::UploadedFilesPromptTest < Minitest::Test
|
||||
EmptyFiles = Struct.new(:attached) do
|
||||
def attached? = attached
|
||||
end
|
||||
AttachedFiles = Class.new(Array) do
|
||||
def attached? = true
|
||||
end
|
||||
FakeUpload = Struct.new(:filename, :content_type, :content, keyword_init: true) do
|
||||
def byte_size = content.bytesize
|
||||
def download = content
|
||||
end
|
||||
|
||||
def setup
|
||||
@tmpdir = Dir.mktmpdir("opencode-rails-uploaded-prompt-test-")
|
||||
@@ -52,4 +59,130 @@ class Opencode::UploadedFilesPromptTest < Minitest::Test
|
||||
assert_equal %i[sandbox_file_names text].sort,
|
||||
Opencode::UploadedFilesPrompt.instance_methods(false).sort
|
||||
end
|
||||
|
||||
def test_copies_an_upload_without_domain_specific_instructions
|
||||
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "hello")
|
||||
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
|
||||
prompt = Opencode::UploadedFilesPrompt.new(
|
||||
user_message: message,
|
||||
sandbox_path: @tmpdir,
|
||||
sandbox_name_for: ->(_file) { "upload.txt" }
|
||||
)
|
||||
|
||||
assert_equal "hello", File.binread(File.join(@tmpdir, "upload.txt"))
|
||||
assert_includes prompt.text, "Read each file thoroughly before responding:"
|
||||
refute_includes prompt.text, "legal claims"
|
||||
refute_includes prompt.text, "reference materials"
|
||||
end
|
||||
|
||||
def test_rejects_a_sibling_prefix_escape
|
||||
sandbox = File.join(@tmpdir, "foo")
|
||||
sibling = File.join(@tmpdir, "foobar")
|
||||
FileUtils.mkdir_p(sibling)
|
||||
target = File.join(sibling, "target.txt")
|
||||
File.write(target, "safe")
|
||||
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "overwritten")
|
||||
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
|
||||
|
||||
assert_raises(ArgumentError) do
|
||||
Opencode::UploadedFilesPrompt.new(
|
||||
user_message: message,
|
||||
sandbox_path: sandbox,
|
||||
sandbox_name_for: ->(_file) { "../foobar/target.txt" }
|
||||
)
|
||||
end
|
||||
assert_equal "safe", File.read(target)
|
||||
end
|
||||
|
||||
def test_atomically_replaces_a_destination_symlink_without_following_it
|
||||
outside = File.join(@tmpdir, "outside.txt")
|
||||
sandbox = File.join(@tmpdir, "sandbox")
|
||||
FileUtils.mkdir_p(sandbox)
|
||||
File.write(outside, "safe")
|
||||
File.symlink(outside, File.join(sandbox, "upload.txt"))
|
||||
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "overwritten")
|
||||
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
|
||||
|
||||
Opencode::UploadedFilesPrompt.new(
|
||||
user_message: message,
|
||||
sandbox_path: sandbox,
|
||||
sandbox_name_for: ->(_file) { "upload.txt" }
|
||||
)
|
||||
|
||||
assert_equal "safe", File.read(outside)
|
||||
assert_equal "overwritten", File.read(File.join(sandbox, "upload.txt"))
|
||||
refute File.symlink?(File.join(sandbox, "upload.txt"))
|
||||
end
|
||||
|
||||
def test_retried_copy_refreshes_an_existing_sandbox_file
|
||||
sandbox = File.join(@tmpdir, "sandbox")
|
||||
FileUtils.mkdir_p(sandbox)
|
||||
File.write(File.join(sandbox, "upload.txt"), "stale")
|
||||
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "fresh")
|
||||
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
|
||||
|
||||
Opencode::UploadedFilesPrompt.new(
|
||||
user_message: message,
|
||||
sandbox_path: sandbox,
|
||||
sandbox_name_for: ->(_file) { "upload.txt" }
|
||||
)
|
||||
|
||||
assert_equal "fresh", File.read(File.join(sandbox, "upload.txt"))
|
||||
end
|
||||
|
||||
def test_rejects_a_sandbox_root_replaced_during_the_copy
|
||||
sandbox = File.join(@tmpdir, "sandbox")
|
||||
moved_sandbox = File.join(@tmpdir, "moved-sandbox")
|
||||
FileUtils.mkdir_p(sandbox)
|
||||
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "private")
|
||||
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
|
||||
create = Tempfile.method(:create)
|
||||
replace_root = lambda do |*args, **kwargs, &block|
|
||||
File.rename(sandbox, moved_sandbox)
|
||||
FileUtils.mkdir_p(sandbox)
|
||||
create.call(*args, **kwargs, &block)
|
||||
end
|
||||
|
||||
Tempfile.stub(:create, replace_root) do
|
||||
assert_raises(ArgumentError) do
|
||||
Opencode::UploadedFilesPrompt.new(
|
||||
user_message: message,
|
||||
sandbox_path: sandbox,
|
||||
sandbox_name_for: ->(_file) { "upload.txt" }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
refute File.exist?(File.join(sandbox, "upload.txt"))
|
||||
refute File.exist?(File.join(moved_sandbox, "upload.txt"))
|
||||
end
|
||||
|
||||
def test_new_upload_uses_the_normal_umask_file_mode
|
||||
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "hello")
|
||||
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
|
||||
|
||||
Opencode::UploadedFilesPrompt.new(
|
||||
user_message: message,
|
||||
sandbox_path: @tmpdir,
|
||||
sandbox_name_for: ->(_file) { "upload.txt" }
|
||||
)
|
||||
|
||||
assert_equal 0o666 & ~File.umask, File.stat(File.join(@tmpdir, "upload.txt")).mode & 0o777
|
||||
end
|
||||
|
||||
def test_retried_copy_preserves_an_existing_file_mode
|
||||
existing = File.join(@tmpdir, "upload.txt")
|
||||
File.write(existing, "stale")
|
||||
File.chmod(0o640, existing)
|
||||
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "fresh")
|
||||
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
|
||||
|
||||
Opencode::UploadedFilesPrompt.new(
|
||||
user_message: message,
|
||||
sandbox_path: @tmpdir,
|
||||
sandbox_name_for: ->(_file) { "upload.txt" }
|
||||
)
|
||||
|
||||
assert_equal 0o640, File.stat(existing).mode & 0o777
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user