Finalize alpha9 release safeguards

This commit is contained in:
2026-07-20 20:51:01 -07:00
parent 427bd47648
commit 9937e45f5e
7 changed files with 68 additions and 110 deletions

View File

@@ -40,19 +40,19 @@ jobs:
- name: Build gem
run: gem build opencode-rails.gemspec
- name: Verify gems load after install
- name: Verify published client and installed gems
run: |
client_dir="$(bundle show opencode-ruby)"
(cd "$client_dir" && gem build opencode-ruby.gemspec)
client_version="$(ruby -I"$client_dir/lib" -ropencode/version -e 'print Opencode::VERSION')"
client_gem="$client_dir/opencode-ruby-${client_version}.gem"
client_version="$(bundle exec ruby -ropencode/version -e 'print Opencode::VERSION')"
rails_gem="opencode-rails-$(ruby -Ilib -ropencode/rails_version -e 'print Opencode::RAILS_VERSION').gem"
bundle_gem_path="$(bundle exec ruby -e 'puts Gem.path.join(":")')"
registry_dir="${RUNNER_TEMP}/opencode-ruby-registry-${{ matrix.ruby }}"
mkdir -p "$registry_dir"
(cd "$registry_dir" && gem fetch opencode-ruby --version "$client_version" --prerelease --clear-sources --source https://rubygems.org)
client_gem="$registry_dir/opencode-ruby-${client_version}.gem"
export GEM_HOME="${RUNNER_TEMP}/opencode-rails-${{ matrix.ruby }}"
export GEM_PATH="${GEM_HOME}:${bundle_gem_path}"
export GEM_PATH="${GEM_HOME}"
mkdir -p "$GEM_HOME"
gem install --local "$client_gem" --no-document
gem install --local "$rails_gem" --no-document
gem install "$client_gem" --no-document --clear-sources --source https://rubygems.org
gem install "$rails_gem" --no-document --clear-sources --source https://rubygems.org
ruby -ropencode-rails -e '
root = File.realpath(ENV.fetch("GEM_HOME"))
%w[opencode-ruby opencode-rails].each do |name|

View File

@@ -19,9 +19,10 @@
- Reject hard-linked sandbox artifacts and open files with nonblocking,
no-follow descriptor checks so FIFO, socket, symlink, and device swaps fail
closed without hanging artifact collection.
- Make the reference integration deny every OpenCode tool by default and use a
realpath-validated per-conversation directory outside Git worktrees. Hosts
must add their own OS or container boundary before allowing filesystem tools.
- Make the reference integration deny every OpenCode tool by default and
require project-local OpenCode configuration to be disabled at server
startup. Hosts must add their own OS or container boundary before allowing
filesystem tools.
- Load every runtime standard library explicitly and align the shipped
permissions, observer, Turn, prompt, and instrumentation examples with the
actual APIs.
@@ -34,6 +35,8 @@
use Ruby 4.0 for release builds.
- Fail the trusted-publishing job before release when the pushed tag does not
match `Opencode::RAILS_VERSION`.
- Refuse to publish Rails until the RubyGems client package exists and loads
with the locally built Rails package in an isolated gem repository.
## 0.0.1.alpha8 - 2026-07-20

View File

@@ -57,12 +57,15 @@ accepted turn without posting its prompt again, while hardening SSE framing.
The unrepaired `opencode-ruby` 0.0.1.alpha8 package was yanked, and
`opencode-rails` alpha8 was never published.
`opencode-rails` 0.0.1.alpha9 is a release candidate and is not yet confirmed
published on RubyGems. The
`release.yml` workflow is prepared for RubyGems
trusted publishing, but the gem's trusted publisher must be registered and
verified for that workflow and its `release` environment. Until the registry
result is verified, pushing a `v*` tag does not guarantee publication. Trusted
publishing does not require a long-lived RubyGems API key.
published on RubyGems. Because the gem has no RubyGems entry yet, create a
pending trusted publisher for gem `opencode-rails`, repository owner
`ajaynomics`, repository `opencode-rails`, workflow `release.yml`, and
environment `release`. RubyGems converts it to a normal trusted publisher after
the first successful push. The workflow also refuses to publish until the
RubyGems `opencode-ruby` alpha9 package exists and installs and loads with the
locally built Rails package. Until the registry result is verified, pushing a
`v*` tag does not guarantee publication. Trusted publishing does not require a
long-lived RubyGems API key.
## Quickstart
@@ -100,10 +103,10 @@ class GenerateResponseJob < ApplicationJob
unless user_message.conversation_id == conversation.id
raise ArgumentError, "User and assistant messages must belong to the same conversation"
end
sandbox_directory = sandbox_directory_for(conversation)
working_directory = File.realpath(ENV.fetch("OPENCODE_WORKING_DIRECTORY"))
client = Opencode::Client.new(
base_url: ENV.fetch("OPENCODE_URL"),
directory: sandbox_directory.to_s
directory: working_directory
)
session = Opencode::Session.new(
@@ -139,43 +142,20 @@ class GenerateResponseJob < ApplicationJob
{ permission: "*", pattern: "*", action: "deny" }
]
end
def sandbox_directory_for(conversation)
identifier = conversation.id.to_s
unless identifier.match?(/\A[0-9A-Za-z_-]+\z/)
raise ArgumentError, "Conversation id is not safe for a directory name"
end
root = Pathname.new(ENV.fetch("OPENCODE_SANDBOX_ROOT")).expand_path
root.mkpath
root = root.realpath
if root.ascend.any? { |directory| directory.join(".git").exist? }
raise ArgumentError, "OPENCODE_SANDBOX_ROOT must be outside every Git worktree"
end
directory = root.join(identifier)
directory.mkpath
stat = directory.lstat
unless stat.directory? && !stat.symlink?
raise ArgumentError, "Conversation sandbox must be a real directory"
end
resolved = directory.realpath
unless resolved.to_s.start_with?("#{root}#{File::SEPARATOR}")
raise ArgumentError, "Conversation sandbox escapes its root"
end
resolved
end
end
```
`OPENCODE_SANDBOX_ROOT` must be outside every Git worktree and mounted at the
same absolute path in the OpenCode server. OpenCode treats an entire detected
worktree as internal, so a directory inside the application repository is not
an `external_directory` boundary. This quickstart intentionally grants no
filesystem tools: OpenCode permissions alone are not an OS sandbox. Add
product-specific allows only when the OpenCode process also has an independent
container or operating-system boundary around the conversation directory.
Run the OpenCode server with `OPENCODE_DISABLE_PROJECT_CONFIG=1`. OpenCode loads
project configuration, plugins, and instructions while opening a directory,
before session permissions exist; the wildcard deny rule cannot constrain that
bootstrap. Point `OPENCODE_WORKING_DIRECTORY` at a pre-provisioned directory
mounted at the same absolute path in Rails and OpenCode. Treat the server's
global configuration, plugins, and instructions as privileged administrator
code and verify the setting against the deployed OpenCode version.
This quickstart intentionally grants no filesystem tools: OpenCode permissions
alone are not an OS sandbox. Add product-specific allows only when the OpenCode
process also has an independent container or operating-system boundary.
`Opencode::Session` applies permissions only when it creates a session. Before
deploying a directory or permission-policy change, recreate persisted sessions

View File

@@ -28,6 +28,9 @@
# 4. Permissions builder: per-product session permission rules.
#
# Contract-tested reference. Copy what you need and adapt it to your domain.
# The OpenCode server process must run with
# OPENCODE_DISABLE_PROJECT_CONFIG=1; session permissions are too late to
# constrain project configuration, plugins, or instructions at bootstrap.
# ----------------------------------------------------------------------
# 1. config/initializers/opencode.rb
@@ -76,10 +79,10 @@ class GenerateResponseJob < ApplicationJob
unless user_message.conversation_id == conversation.id
raise ArgumentError, "User and assistant messages must belong to the same conversation"
end
sandbox_directory = sandbox_directory_for(conversation)
working_directory = File.realpath(ENV.fetch("OPENCODE_WORKING_DIRECTORY"))
client = Opencode::Client.new(
base_url: ENV.fetch("OPENCODE_URL"),
directory: sandbox_directory.to_s
directory: working_directory
)
# Session: AR-coupled, row-locked, idempotent. ensure! creates the
@@ -128,42 +131,12 @@ class GenerateResponseJob < ApplicationJob
]
end
def sandbox_directory_for(conversation)
# The root must be mounted at the same absolute path in the OpenCode server.
# Keeping it outside Git worktrees makes external_directory the boundary.
identifier = conversation.id.to_s
unless identifier.match?(/\A[0-9A-Za-z_-]+\z/)
raise ArgumentError, "Conversation id is not safe for a directory name"
end
root = Pathname.new(ENV.fetch("OPENCODE_SANDBOX_ROOT")).expand_path
root.mkpath
root = root.realpath
if root.ascend.any? { |directory| directory.join(".git").exist? }
raise ArgumentError, "OPENCODE_SANDBOX_ROOT must be outside every Git worktree"
end
directory = root.join(identifier)
directory.mkpath
stat = directory.lstat
unless stat.directory? && !stat.symlink?
raise ArgumentError, "Conversation sandbox must be a real directory"
end
resolved = directory.realpath
unless resolved.to_s.start_with?("#{root}#{File::SEPARATOR}")
raise ArgumentError, "Conversation sandbox escapes its root"
end
resolved
end
def build_system_context(conversation)
# System prompt context the agent gets. Your app probably already
# has helpers for this; the gem doesn't impose a shape.
<<~CONTEXT
User: #{conversation.user.name}
Conversation: #{conversation.id}
Sandbox: #{sandbox_directory_for(conversation)}
CONTEXT
end
end

View File

@@ -37,19 +37,15 @@ class ExampleTest < Minitest::Test
def test_example_uses_current_fail_closed_permission_rules
refute_match(/\{\s*type:/, SOURCE)
assert_includes SOURCE, 'sandbox_directory = sandbox_directory_for(conversation)'
assert_includes SOURCE, 'directory: sandbox_directory.to_s'
assert_includes SOURCE, 'working_directory = File.realpath(ENV.fetch("OPENCODE_WORKING_DIRECTORY"))'
assert_includes SOURCE, "directory: working_directory"
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, "OPENCODE_DISABLE_PROJECT_CONFIG=1"
refute_includes SOURCE, "ConversationSandbox"
assert_includes SOURCE, "permissions only when it creates a session"
assert_match(/recreate persisted sessions/i, SOURCE)
refute_includes SOURCE, 'Sandbox: /sandbox/#{conversation.id}'
refute_includes SOURCE, "Sandbox:"
end
def test_example_permission_order_denies_every_tool

View File

@@ -48,23 +48,23 @@ class ReadmeTest < Minitest::Test
assert_includes @readme, %(ref: "#{client_ref}")
assert_includes @readme,
"`opencode-rails` #{Opencode::RAILS_VERSION} is a release candidate"
assert_includes @readme, "pushing a `v*` tag does not guarantee publication"
assert_match(/pushing a\s+`v\*` tag does not guarantee publication/, @readme)
assert_includes @readme, "pending trusted publisher for gem `opencode-rails`"
assert_includes @readme, "workflow `release.yml`"
assert_includes @readme, "environment `release`"
end
def test_quickstart_uses_current_fail_closed_permission_rules
refute_match(/\{\s*type:/, @quickstart)
assert_includes @quickstart, 'sandbox_directory = sandbox_directory_for(conversation)'
assert_includes @quickstart, 'directory: sandbox_directory.to_s'
assert_includes @quickstart,
'working_directory = File.realpath(ENV.fetch("OPENCODE_WORKING_DIRECTORY"))'
assert_includes @quickstart, "directory: working_directory"
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"
refute_includes @quickstart, "ConversationSandbox"
assert_includes @readme, "same absolute path in Rails and OpenCode"
assert_includes @readme, "`OPENCODE_DISABLE_PROJECT_CONFIG=1`"
assert_includes @readme, "before session permissions exist"
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"

View File

@@ -64,12 +64,18 @@ class ReleaseWorkflowTest < Minitest::Test
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, "bundle exec ruby -ropencode/version"
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, 'GEM_PATH="${GEM_HOME}"'
refute_includes commands, "Gem.path.join"
assert_includes commands, 'client_gem="$registry_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 fetch opencode-ruby --version "$client_version" --prerelease'
assert_includes commands, "--clear-sources --source https://rubygems.org"
assert_includes commands,
'gem install "$client_gem" --no-document --clear-sources --source https://rubygems.org'
assert_includes commands,
'gem install "$rails_gem" --no-document --clear-sources --source https://rubygems.org'
assert_includes commands, "Gem.loaded_specs.fetch(name).full_gem_path"
assert_includes commands, "ruby -ropencode-rails"
end