Prepare repaired alpha9 release
This commit is contained in:
@@ -37,8 +37,35 @@ class ExampleTest < Minitest::Test
|
||||
|
||||
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" }'
|
||||
assert_includes SOURCE, 'sandbox_directory = sandbox_directory_for(conversation)'
|
||||
assert_includes SOURCE, 'directory: sandbox_directory.to_s'
|
||||
assert_includes SOURCE, '{ permission: "*", pattern: "*", action: "deny" }'
|
||||
refute_includes SOURCE, 'action: "allow"'
|
||||
assert_includes SOURCE, 'ENV.fetch("OPENCODE_SANDBOX_ROOT")'
|
||||
assert_includes SOURCE, "root = root.realpath"
|
||||
assert_includes SOURCE, 'directory.join(".git").exist?'
|
||||
assert_includes SOURCE, 'identifier.match?(/\A[0-9A-Za-z_-]+\z/)'
|
||||
assert_includes SOURCE, "stat = directory.lstat"
|
||||
assert_includes SOURCE, "Conversation sandbox escapes its root"
|
||||
assert_includes SOURCE, "permissions only when it creates a session"
|
||||
assert_match(/recreate persisted sessions/i, SOURCE)
|
||||
refute_includes SOURCE, 'Sandbox: /sandbox/#{conversation.id}'
|
||||
end
|
||||
|
||||
def test_example_permission_order_denies_every_tool
|
||||
body = SOURCE[/def permission_rules_for\(_conversation\)\n(?<body>.*?)^ end/m, :body]
|
||||
rules = eval(body, binding, PATH)
|
||||
action_for = lambda do |permission, pattern|
|
||||
rules.reverse.find do |rule|
|
||||
(rule.fetch(:permission) == "*" || rule.fetch(:permission) == permission) &&
|
||||
(rule.fetch(:pattern) == "*" || rule.fetch(:pattern) == pattern)
|
||||
end.fetch(:action)
|
||||
end
|
||||
|
||||
assert_equal "deny", action_for.call("read", "arbitrary/worktree/path")
|
||||
assert_equal "deny", action_for.call("edit", "arbitrary/worktree/path")
|
||||
assert_equal "deny", action_for.call("external_directory", "/outside/*")
|
||||
assert_equal "deny", action_for.call("grep", "secret")
|
||||
assert_equal "deny", action_for.call("lsp", "*")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
require "test_helper"
|
||||
require "fileutils"
|
||||
require "socket"
|
||||
require "tmpdir"
|
||||
require "marcel" # SandboxFile#content_type uses Marcel::MimeType.for
|
||||
|
||||
@@ -76,6 +77,34 @@ class Opencode::SandboxFileTest < Minitest::Test
|
||||
assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content }
|
||||
end
|
||||
|
||||
def test_safe_rejects_a_hard_link
|
||||
File.unlink(@path)
|
||||
outside = File.join(@outside_dir, "private.txt")
|
||||
File.write(outside, "private")
|
||||
File.link(outside, @path)
|
||||
file = Opencode::SandboxFile.new(
|
||||
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
|
||||
)
|
||||
|
||||
refute file.safe?
|
||||
end
|
||||
|
||||
def test_content_rejects_a_hard_link_added_immediately_before_open
|
||||
file = Opencode::SandboxFile.new(
|
||||
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
|
||||
)
|
||||
outside_link = File.join(@outside_dir, "linked-notes.md")
|
||||
real_open = File.method(:open)
|
||||
link_before_open = lambda do |path, mode, *args, **kwargs, &block|
|
||||
File.link(path, outside_link)
|
||||
real_open.call(path, mode, *args, **kwargs, &block)
|
||||
end
|
||||
|
||||
File.stub(:open, link_before_open) do
|
||||
assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content }
|
||||
end
|
||||
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
|
||||
@@ -98,6 +127,76 @@ class Opencode::SandboxFileTest < Minitest::Test
|
||||
end
|
||||
end
|
||||
|
||||
def test_content_rejects_a_fifo_swapped_in_immediately_before_open_without_blocking
|
||||
file = Opencode::SandboxFile.new(
|
||||
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
|
||||
)
|
||||
real_open = File.method(:open)
|
||||
swap_before_open = lambda do |path, mode, *args, **kwargs, &block|
|
||||
File.unlink(path)
|
||||
File.mkfifo(path)
|
||||
assert_kind_of Integer, mode
|
||||
assert_operator mode & File::NONBLOCK, :>, 0
|
||||
assert_operator mode & File::NOFOLLOW, :>, 0
|
||||
real_open.call(path, mode, *args, **kwargs, &block)
|
||||
end
|
||||
|
||||
File.stub(:open, swap_before_open) do
|
||||
assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content }
|
||||
end
|
||||
end
|
||||
|
||||
def test_content_rejects_a_symlink_swapped_in_immediately_before_open
|
||||
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")
|
||||
real_open = File.method(:open)
|
||||
swap_before_open = lambda do |path, mode, *args, **kwargs, &block|
|
||||
File.unlink(path)
|
||||
File.symlink(outside, path)
|
||||
real_open.call(path, mode, *args, **kwargs, &block)
|
||||
end
|
||||
|
||||
File.stub(:open, swap_before_open) do
|
||||
assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content }
|
||||
end
|
||||
end
|
||||
|
||||
def test_safe_rejects_a_socket_swapped_in_immediately_before_open
|
||||
file = Opencode::SandboxFile.new(
|
||||
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
|
||||
)
|
||||
real_open = File.method(:open)
|
||||
socket = nil
|
||||
swap_before_open = lambda do |path, mode, *args, **kwargs, &block|
|
||||
File.unlink(path)
|
||||
socket = UNIXServer.new(path)
|
||||
real_open.call(path, mode, *args, **kwargs, &block)
|
||||
end
|
||||
|
||||
File.stub(:open, swap_before_open) do
|
||||
refute file.safe?
|
||||
end
|
||||
ensure
|
||||
socket&.close
|
||||
end
|
||||
|
||||
def test_safe_fails_closed_when_secure_open_flags_are_unavailable
|
||||
file = Opencode::SandboxFile.new(
|
||||
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
|
||||
)
|
||||
real_const_defined = File.method(:const_defined?)
|
||||
const_defined = lambda do |name, inherit = true|
|
||||
name == :NOFOLLOW ? false : real_const_defined.call(name, inherit)
|
||||
end
|
||||
|
||||
File.stub(:const_defined?, const_defined) do
|
||||
refute file.safe?
|
||||
end
|
||||
end
|
||||
|
||||
def test_content_returns_binary_empty_string_for_a_zero_byte_file
|
||||
File.write(@path, "")
|
||||
file = Opencode::SandboxFile.new(
|
||||
|
||||
@@ -53,11 +53,21 @@ class ReadmeTest < Minitest::Test
|
||||
|
||||
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"'
|
||||
assert_includes @quickstart, 'sandbox_directory = sandbox_directory_for(conversation)'
|
||||
assert_includes @quickstart, 'directory: sandbox_directory.to_s'
|
||||
assert_includes @quickstart, '{ permission: "*", pattern: "*", action: "deny" }'
|
||||
refute_includes @quickstart, 'action: "allow"'
|
||||
assert_includes @quickstart, 'ENV.fetch("OPENCODE_SANDBOX_ROOT")'
|
||||
assert_includes @quickstart, "root = root.realpath"
|
||||
assert_includes @quickstart, 'directory.join(".git").exist?'
|
||||
assert_includes @quickstart, 'identifier.match?(/\A[0-9A-Za-z_-]+\z/)'
|
||||
assert_includes @quickstart, "stat = directory.lstat"
|
||||
assert_includes @quickstart, "Conversation sandbox escapes its root"
|
||||
assert_includes @readme, "must be outside every Git worktree"
|
||||
assert_includes @readme, "same absolute path in the OpenCode server"
|
||||
assert_match(/intentionally grants no\s+filesystem tools/, @readme)
|
||||
assert_includes @readme, "permissions only when it creates a session"
|
||||
assert_includes @readme, "recreate persisted sessions"
|
||||
end
|
||||
|
||||
def test_quickstart_uses_the_enqueued_user_message
|
||||
|
||||
@@ -17,6 +17,10 @@ class ReleaseWorkflowTest < Minitest::Test
|
||||
workflow.fetch("jobs").fetch("push")
|
||||
end
|
||||
|
||||
def verify_job
|
||||
workflow.fetch("jobs").fetch("verify")
|
||||
end
|
||||
|
||||
def test_release_job_is_inert_on_non_github_runners
|
||||
assert_equal "${{ github.server_url == 'https://github.com' }}", push_job.fetch("if")
|
||||
end
|
||||
@@ -32,33 +36,41 @@ class ReleaseWorkflowTest < Minitest::Test
|
||||
setup_ruby = steps.find { |step| step["uses"] == SETUP_RUBY_ACTION }
|
||||
|
||||
assert_equal "4.0", setup_ruby.dig("with", "ruby-version")
|
||||
assert_equal true, setup_ruby.dig("with", "bundler-cache")
|
||||
assert_equal 1, steps.count { |step| step["uses"] == RELEASE_GEM_ACTION }
|
||||
assert steps.filter_map { |step| step["uses"] }.all? { |uses| uses.match?(/@[0-9a-f]{40}\z/) }
|
||||
refute steps.any? { |step| step.fetch("run", "").match?(/\bgem\s+push\b/) }
|
||||
refute steps.any? { |step| step.key?("run") }
|
||||
end
|
||||
|
||||
def test_release_tag_must_match_the_gem_version_before_publish
|
||||
steps = push_job.fetch("steps")
|
||||
preflight_index = steps.index { |step| step["name"] == "Verify tag matches gem version" }
|
||||
publish_index = steps.index { |step| step["uses"] == RELEASE_GEM_ACTION }
|
||||
preflight = verify_job.fetch("steps").find { |step| step["name"] == "Verify tag matches gem version" }
|
||||
|
||||
refute_nil preflight_index
|
||||
refute_nil publish_index
|
||||
assert_operator preflight_index, :<, publish_index
|
||||
|
||||
preflight = steps.fetch(preflight_index)
|
||||
refute_nil preflight
|
||||
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 }
|
||||
def test_verification_job_has_read_only_credentials
|
||||
assert_equal({ "contents" => "read" }, verify_job.fetch("permissions"))
|
||||
|
||||
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")
|
||||
checkout = verify_job.fetch("steps").find { |step| step.fetch("uses", "").start_with?("actions/checkout@") }
|
||||
assert_equal false, checkout.dig("with", "persist-credentials")
|
||||
end
|
||||
|
||||
def test_release_verifies_the_supported_matrix_and_installed_gems_before_publish
|
||||
assert_equal %w[3.2 3.3 3.4 4.0], verify_job.dig("strategy", "matrix", "ruby")
|
||||
assert_equal "verify", push_job.fetch("needs")
|
||||
|
||||
commands = verify_job.fetch("steps").filter_map { |step| step["run"] }.join("\n")
|
||||
assert_includes commands, "bundle exec rake test"
|
||||
assert_includes commands, "gem build opencode-rails.gemspec"
|
||||
assert_includes commands, "bundle exec ruby -e"
|
||||
assert_includes commands, 'GEM_HOME="${RUNNER_TEMP}/opencode-rails-${{ matrix.ruby }}"'
|
||||
assert_includes commands, 'client_gem="$client_dir/opencode-ruby-${client_version}.gem"'
|
||||
assert_includes commands, 'rails_gem="opencode-rails-$(ruby -Ilib -ropencode/rails_version'
|
||||
assert_includes commands, 'gem install --local "$client_gem" --no-document'
|
||||
assert_includes commands, 'gem install --local "$rails_gem" --no-document'
|
||||
assert_includes commands, "Gem.loaded_specs.fetch(name).full_gem_path"
|
||||
assert_includes commands, "ruby -ropencode-rails"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -27,7 +27,7 @@ class WorkflowContractTest < Minitest::Test
|
||||
workflow_uses(workflow)
|
||||
end
|
||||
|
||||
assert_equal 5, action_uses.length
|
||||
assert_equal 7, action_uses.length
|
||||
action_uses.each do |action_use|
|
||||
action, separator, revision = action_use.rpartition("@")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user