Harden alpha8 publication safety
This commit is contained in:
44
test/example_test.rb
Normal file
44
test/example_test.rb
Normal file
@@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
require "ripper"
|
||||
|
||||
class ExampleTest < Minitest::Test
|
||||
PATH = File.expand_path("../examples/rails_integration.rb", __dir__)
|
||||
SOURCE = File.read(PATH)
|
||||
|
||||
def test_example_parses
|
||||
assert Ripper.sexp(SOURCE)
|
||||
end
|
||||
|
||||
def test_example_uses_turn_collaborator_contracts
|
||||
assert_includes SOURCE, "def perform(assistant_message, user_message)"
|
||||
assert_includes SOURCE, "session_for: session"
|
||||
assert_includes SOURCE, "system_context: ->(record)"
|
||||
assert_includes SOURCE, "agent_name: ->(_record)"
|
||||
assert_includes SOURCE, '{ "build" }'
|
||||
assert_includes SOURCE, "def watch(reply)"
|
||||
assert_includes SOURCE, "include Opencode::ReplyObserver"
|
||||
assert_includes SOURCE, 'ActiveSupport::Notifications.instrument("assistant.#{name}", payload)'
|
||||
refute_includes SOURCE, 'Opencode::Tracer.new(prefix: "opencode.")'
|
||||
refute_includes SOURCE, ".order(:created_at).last"
|
||||
end
|
||||
|
||||
def test_example_uses_reply_indexes_for_dom_identity
|
||||
assert_includes SOURCE, 'target: "part_#{index}_message_#{@message.id}"'
|
||||
assert_includes SOURCE, "locals: { part: part, index: index, message: @message }"
|
||||
refute_includes SOURCE, "part['id']"
|
||||
end
|
||||
|
||||
def test_example_does_not_claim_unimplemented_mid_stream_persistence
|
||||
refute_includes SOURCE, "Mid-stream parts_json snapshotting"
|
||||
refute_includes SOURCE, "update_columns"
|
||||
end
|
||||
|
||||
def test_example_uses_current_fail_closed_permission_rules
|
||||
refute_match(/\{\s*type:/, SOURCE)
|
||||
assert_includes SOURCE, '{ permission: "bash", pattern: "*", action: "deny" }'
|
||||
assert_includes SOURCE, '{ permission: "external_directory", pattern: "*", action: "deny" }'
|
||||
assert_includes SOURCE, '{ permission: "edit", pattern: "*", action: "deny" }'
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -50,4 +50,23 @@ class ReadmeTest < Minitest::Test
|
||||
"`opencode-rails` #{Opencode::RAILS_VERSION} is a release candidate"
|
||||
assert_includes @readme, "pushing a `v*` tag does not guarantee publication"
|
||||
end
|
||||
|
||||
def test_quickstart_uses_current_fail_closed_permission_rules
|
||||
refute_match(/\{\s*type:/, @quickstart)
|
||||
assert_includes @quickstart, '{ permission: "bash", pattern: "*", action: "deny" }'
|
||||
assert_includes @quickstart, '{ permission: "external_directory", pattern: "*", action: "deny" }'
|
||||
assert_includes @quickstart, '{ permission: "edit", pattern: "*", action: "deny" }'
|
||||
assert_includes @quickstart, 'permission: "edit",'
|
||||
assert_includes @quickstart, 'action: "allow"'
|
||||
end
|
||||
|
||||
def test_quickstart_uses_the_enqueued_user_message
|
||||
assert_includes @quickstart, "def perform(assistant_message, user_message)"
|
||||
refute_includes @quickstart, ".where(role: :user).last"
|
||||
end
|
||||
|
||||
def test_instrumentation_docs_match_the_configured_tracer_prefix
|
||||
assert_includes @readme, "`assistant.response.started`"
|
||||
assert_includes @readme, "Turn events flow through the injected tracer"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -50,4 +50,15 @@ class ReleaseWorkflowTest < Minitest::Test
|
||||
assert_equal "${{ github.ref_name }}", preflight.dig("env", "RELEASE_TAG")
|
||||
assert_includes preflight.fetch("run"), "unless Opencode::RAILS_VERSION == expected"
|
||||
end
|
||||
|
||||
def test_release_runs_the_test_suite_before_publish
|
||||
steps = push_job.fetch("steps")
|
||||
test_index = steps.index { |step| step["name"] == "Run tests" }
|
||||
publish_index = steps.index { |step| step["uses"] == RELEASE_GEM_ACTION }
|
||||
|
||||
refute_nil test_index
|
||||
refute_nil publish_index
|
||||
assert_operator test_index, :<, publish_index
|
||||
assert_equal "bundle exec rake test", steps.fetch(test_index).fetch("run")
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user