Prepare repaired alpha9 release
This commit is contained in:
67
.github/workflows/release.yml
vendored
67
.github/workflows/release.yml
vendored
@@ -6,7 +6,64 @@ on:
|
|||||||
- "v*"
|
- "v*"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
ruby: ["3.2", "3.3", "3.4", "4.0"]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Ruby ${{ matrix.ruby }}
|
||||||
|
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
|
||||||
|
with:
|
||||||
|
ruby-version: ${{ matrix.ruby }}
|
||||||
|
bundler-cache: true
|
||||||
|
|
||||||
|
- name: Verify tag matches gem version
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ github.ref_name }}
|
||||||
|
run: >-
|
||||||
|
ruby -Ilib -ropencode/rails_version -e
|
||||||
|
'expected = ENV.fetch("RELEASE_TAG").delete_prefix("v");
|
||||||
|
abort "tag #{expected.inspect} does not match gem #{Opencode::RAILS_VERSION.inspect}"
|
||||||
|
unless Opencode::RAILS_VERSION == expected'
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: bundle exec rake test
|
||||||
|
|
||||||
|
- name: Build gem
|
||||||
|
run: gem build opencode-rails.gemspec
|
||||||
|
|
||||||
|
- name: Verify gems load after install
|
||||||
|
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"
|
||||||
|
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(":")')"
|
||||||
|
export GEM_HOME="${RUNNER_TEMP}/opencode-rails-${{ matrix.ruby }}"
|
||||||
|
export GEM_PATH="${GEM_HOME}:${bundle_gem_path}"
|
||||||
|
mkdir -p "$GEM_HOME"
|
||||||
|
gem install --local "$client_gem" --no-document
|
||||||
|
gem install --local "$rails_gem" --no-document
|
||||||
|
ruby -ropencode-rails -e '
|
||||||
|
root = File.realpath(ENV.fetch("GEM_HOME"))
|
||||||
|
%w[opencode-ruby opencode-rails].each do |name|
|
||||||
|
path = File.realpath(Gem.loaded_specs.fetch(name).full_gem_path)
|
||||||
|
abort "#{name} loaded outside isolated GEM_HOME: #{path}" unless path.start_with?("#{root}/")
|
||||||
|
end
|
||||||
|
puts Opencode::RAILS_VERSION
|
||||||
|
'
|
||||||
|
|
||||||
push:
|
push:
|
||||||
|
needs: verify
|
||||||
if: ${{ github.server_url == 'https://github.com' }}
|
if: ${{ github.server_url == 'https://github.com' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
@@ -21,14 +78,4 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ruby-version: "4.0"
|
ruby-version: "4.0"
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
- name: Verify tag matches gem version
|
|
||||||
env:
|
|
||||||
RELEASE_TAG: ${{ github.ref_name }}
|
|
||||||
run: >-
|
|
||||||
ruby -Ilib -ropencode/rails_version -e
|
|
||||||
'expected = ENV.fetch("RELEASE_TAG").delete_prefix("v");
|
|
||||||
abort "tag #{expected.inspect} does not match gem #{Opencode::RAILS_VERSION.inspect}"
|
|
||||||
unless Opencode::RAILS_VERSION == expected'
|
|
||||||
- name: Run tests
|
|
||||||
run: bundle exec rake test
|
|
||||||
- uses: rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092 # v1.4.0
|
- uses: rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092 # v1.4.0
|
||||||
|
|||||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -1,10 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.0.1.alpha8 - 2026-07-20
|
## 0.0.1.alpha9 - 2026-07-20
|
||||||
|
|
||||||
### Bumped
|
### Bumped
|
||||||
|
|
||||||
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha8`, carrying the
|
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha9`, carrying the
|
||||||
hardened SSE framing parser while retaining the alpha7 subscribe-before-
|
hardened SSE framing parser while retaining the alpha7 subscribe-before-
|
||||||
prompt and at-most-once reconnect contract.
|
prompt and at-most-once reconnect contract.
|
||||||
|
|
||||||
@@ -16,18 +16,33 @@
|
|||||||
destination symlinks without following them, and validate bounded reads from
|
destination symlinks without following them, and validate bounded reads from
|
||||||
the opened sandbox file. Upload copying now requires a traversable
|
the opened sandbox file. Upload copying now requires a traversable
|
||||||
`/proc/self/fd` or `/dev/fd` descriptor filesystem and fails closed without it.
|
`/proc/self/fd` or `/dev/fd` descriptor filesystem and fails closed without it.
|
||||||
|
- 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.
|
||||||
- Load every runtime standard library explicitly and align the shipped
|
- Load every runtime standard library explicitly and align the shipped
|
||||||
permissions, observer, Turn, prompt, and instrumentation examples with the
|
permissions, observer, Turn, prompt, and instrumentation examples with the
|
||||||
actual APIs.
|
actual APIs.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Test the supported runtime surface on Ruby 3.2, 3.3, 3.4, and 4.0.
|
- Gate release on tests plus exact local package installation across Ruby 3.2,
|
||||||
|
3.3, 3.4, and 4.0 in a read-only verification job.
|
||||||
- Pin every third-party CI and release action to an exact reviewed commit and
|
- Pin every third-party CI and release action to an exact reviewed commit and
|
||||||
use Ruby 4.0 for release builds.
|
use Ruby 4.0 for release builds.
|
||||||
- Fail the trusted-publishing job before release when the pushed tag does not
|
- Fail the trusted-publishing job before release when the pushed tag does not
|
||||||
match `Opencode::RAILS_VERSION`.
|
match `Opencode::RAILS_VERSION`.
|
||||||
|
|
||||||
|
## 0.0.1.alpha8 - 2026-07-20
|
||||||
|
|
||||||
|
### Unpublished
|
||||||
|
|
||||||
|
- Superseded after the unrepaired `opencode-ruby` alpha8 package was yanked.
|
||||||
|
No `opencode-rails` alpha8 package was published; use the alpha9 lockstep
|
||||||
|
tuple.
|
||||||
|
|
||||||
## 0.0.1.alpha7 - 2026-07-18
|
## 0.0.1.alpha7 - 2026-07-18
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
2
Gemfile
2
Gemfile
@@ -2,6 +2,6 @@ source "https://rubygems.org"
|
|||||||
|
|
||||||
gem "opencode-ruby",
|
gem "opencode-ruby",
|
||||||
git: "https://github.com/ajaynomics/opencode-ruby.git",
|
git: "https://github.com/ajaynomics/opencode-ruby.git",
|
||||||
ref: "49a161632e6631d3605af5170de00c4688cfcedb"
|
ref: "65a44ca1502926d533e6b4b6692779fa39740218"
|
||||||
|
|
||||||
gemspec
|
gemspec
|
||||||
|
|||||||
74
README.md
74
README.md
@@ -17,12 +17,12 @@ Production-grade [OpenCode](https://opencode.ai) integration for Rails apps. Lay
|
|||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
After both alpha8 gems are confirmed on RubyGems, pin the lockstep tuple:
|
After both alpha9 gems are confirmed on RubyGems, pin the lockstep tuple:
|
||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
# Gemfile
|
# Gemfile
|
||||||
gem "opencode-ruby", "= 0.0.1.alpha8" # wire client + Reply state machine
|
gem "opencode-ruby", "= 0.0.1.alpha9" # wire client + Reply state machine
|
||||||
gem "opencode-rails", "= 0.0.1.alpha8"
|
gem "opencode-rails", "= 0.0.1.alpha9"
|
||||||
```
|
```
|
||||||
|
|
||||||
Until publication is verified, validate this candidate checkout against the
|
Until publication is verified, validate this candidate checkout against the
|
||||||
@@ -32,7 +32,7 @@ exact `opencode-ruby` source it was tested with:
|
|||||||
# Gemfile
|
# Gemfile
|
||||||
gem "opencode-ruby",
|
gem "opencode-ruby",
|
||||||
git: "https://github.com/ajaynomics/opencode-ruby.git",
|
git: "https://github.com/ajaynomics/opencode-ruby.git",
|
||||||
ref: "49a161632e6631d3605af5170de00c4688cfcedb"
|
ref: "65a44ca1502926d533e6b4b6692779fa39740218"
|
||||||
|
|
||||||
gem "opencode-rails",
|
gem "opencode-rails",
|
||||||
path: "../opencode-rails"
|
path: "../opencode-rails"
|
||||||
@@ -50,12 +50,15 @@ Runtime deps: `activerecord`, `activestorage`, `activesupport` (>= 7.1), and
|
|||||||
provide one of those traversable descriptor filesystems; unsupported platforms
|
provide one of those traversable descriptor filesystems; unsupported platforms
|
||||||
fail closed before writing.
|
fail closed before writing.
|
||||||
|
|
||||||
During the alpha series both gems are pinned in lockstep. Version 0.0.1.alpha8
|
During the alpha series both gems are pinned in lockstep. Version 0.0.1.alpha9
|
||||||
retains the subscribe-ready-before-prompt transport contract and reconnects an
|
retains the subscribe-ready-before-prompt transport contract and reconnects an
|
||||||
accepted turn without posting its prompt again, while hardening SSE framing.
|
accepted turn without posting its prompt again, while hardening SSE framing.
|
||||||
|
|
||||||
`opencode-rails` 0.0.1.alpha8 is a release candidate and is not yet confirmed
|
The unrepaired `opencode-ruby` 0.0.1.alpha8 package was yanked, and
|
||||||
published on RubyGems. The `release.yml` workflow is prepared for RubyGems
|
`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
|
trusted publishing, but the gem's trusted publisher must be registered and
|
||||||
verified for that workflow and its `release` environment. Until the registry
|
verified for that workflow and its `release` environment. Until the registry
|
||||||
result is verified, pushing a `v*` tag does not guarantee publication. Trusted
|
result is verified, pushing a `v*` tag does not guarantee publication. Trusted
|
||||||
@@ -97,7 +100,11 @@ class GenerateResponseJob < ApplicationJob
|
|||||||
unless user_message.conversation_id == conversation.id
|
unless user_message.conversation_id == conversation.id
|
||||||
raise ArgumentError, "User and assistant messages must belong to the same conversation"
|
raise ArgumentError, "User and assistant messages must belong to the same conversation"
|
||||||
end
|
end
|
||||||
client = Opencode::Client.new(base_url: ENV.fetch("OPENCODE_URL"))
|
sandbox_directory = sandbox_directory_for(conversation)
|
||||||
|
client = Opencode::Client.new(
|
||||||
|
base_url: ENV.fetch("OPENCODE_URL"),
|
||||||
|
directory: sandbox_directory.to_s
|
||||||
|
)
|
||||||
|
|
||||||
session = Opencode::Session.new(
|
session = Opencode::Session.new(
|
||||||
conversation,
|
conversation,
|
||||||
@@ -127,21 +134,54 @@ class GenerateResponseJob < ApplicationJob
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def permission_rules_for(conversation)
|
def permission_rules_for(_conversation)
|
||||||
[
|
[
|
||||||
{ permission: "bash", pattern: "*", action: "deny" },
|
{ permission: "*", pattern: "*", action: "deny" }
|
||||||
{ permission: "external_directory", pattern: "*", action: "deny" },
|
|
||||||
{ permission: "edit", pattern: "*", action: "deny" },
|
|
||||||
{
|
|
||||||
permission: "edit",
|
|
||||||
pattern: "data/sandbox/#{conversation.id}/**",
|
|
||||||
action: "allow"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
end
|
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
|
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.
|
||||||
|
|
||||||
|
`Opencode::Session` applies permissions only when it creates a session. Before
|
||||||
|
deploying a directory or permission-policy change, recreate persisted sessions
|
||||||
|
with a client scoped to the new directory; an existing session ID does not
|
||||||
|
prove that the new policy was applied.
|
||||||
|
|
||||||
The host record (here `conversation`) must respond to `#title`,
|
The host record (here `conversation`) must respond to `#title`,
|
||||||
`#opencode_session_id`, `#opencode_session_id=`, `#with_lock(&block)`,
|
`#opencode_session_id`, `#opencode_session_id=`, `#with_lock(&block)`,
|
||||||
`#update!`, `#reload`, and `#id`. The assistant message must respond to `#id`,
|
`#update!`, `#reload`, and `#id`. The assistant message must respond to `#id`,
|
||||||
|
|||||||
@@ -76,11 +76,17 @@ class GenerateResponseJob < ApplicationJob
|
|||||||
unless user_message.conversation_id == conversation.id
|
unless user_message.conversation_id == conversation.id
|
||||||
raise ArgumentError, "User and assistant messages must belong to the same conversation"
|
raise ArgumentError, "User and assistant messages must belong to the same conversation"
|
||||||
end
|
end
|
||||||
client = Opencode::Client.new(base_url: ENV.fetch("OPENCODE_URL"))
|
sandbox_directory = sandbox_directory_for(conversation)
|
||||||
|
client = Opencode::Client.new(
|
||||||
|
base_url: ENV.fetch("OPENCODE_URL"),
|
||||||
|
directory: sandbox_directory.to_s
|
||||||
|
)
|
||||||
|
|
||||||
# Session: AR-coupled, row-locked, idempotent. ensure! creates the
|
# Session: AR-coupled, row-locked, idempotent. ensure! creates the
|
||||||
# OpenCode session if conversation.opencode_session_id is blank;
|
# OpenCode session if conversation.opencode_session_id is blank;
|
||||||
# returns the existing id otherwise.
|
# returns the existing id otherwise.
|
||||||
|
# Session applies permissions only when it creates a session.
|
||||||
|
# Recreate persisted sessions before deploying a directory or policy change.
|
||||||
session = Opencode::Session.new(
|
session = Opencode::Session.new(
|
||||||
conversation,
|
conversation,
|
||||||
permissions_for: ->(record) { permission_rules_for(record) },
|
permissions_for: ->(record) { permission_rules_for(record) },
|
||||||
@@ -114,28 +120,50 @@ class GenerateResponseJob < ApplicationJob
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def permission_rules_for(conversation)
|
def permission_rules_for(_conversation)
|
||||||
# Per-product permissions. The shape mirrors what
|
# Per-product permissions. The shape mirrors what
|
||||||
# opencode-ruby's Client#create_session expects in `permissions:`.
|
# opencode-ruby's Client#create_session expects in `permissions:`.
|
||||||
[
|
[
|
||||||
{ permission: "bash", pattern: "*", action: "deny" },
|
{ permission: "*", pattern: "*", action: "deny" }
|
||||||
{ permission: "external_directory", pattern: "*", action: "deny" },
|
|
||||||
{ permission: "edit", pattern: "*", action: "deny" },
|
|
||||||
{
|
|
||||||
permission: "edit",
|
|
||||||
pattern: "data/sandbox/#{conversation.id}/**",
|
|
||||||
action: "allow"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
end
|
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)
|
def build_system_context(conversation)
|
||||||
# System prompt context the agent gets. Your app probably already
|
# System prompt context the agent gets. Your app probably already
|
||||||
# has helpers for this; the gem doesn't impose a shape.
|
# has helpers for this; the gem doesn't impose a shape.
|
||||||
<<~CONTEXT
|
<<~CONTEXT
|
||||||
User: #{conversation.user.name}
|
User: #{conversation.user.name}
|
||||||
Conversation: #{conversation.id}
|
Conversation: #{conversation.id}
|
||||||
Sandbox: /sandbox/#{conversation.id}
|
Sandbox: #{sandbox_directory_for(conversation)}
|
||||||
CONTEXT
|
CONTEXT
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,5 +11,5 @@
|
|||||||
# We can't reuse the same constant from a second gem, so we use a
|
# We can't reuse the same constant from a second gem, so we use a
|
||||||
# distinct, non-namespaced constant.
|
# distinct, non-namespaced constant.
|
||||||
module Opencode
|
module Opencode
|
||||||
RAILS_VERSION = "0.0.1.alpha8"
|
RAILS_VERSION = "0.0.1.alpha9"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -84,16 +84,19 @@ module Opencode
|
|||||||
|
|
||||||
def with_safe_file
|
def with_safe_file
|
||||||
before = File.lstat(path)
|
before = File.lstat(path)
|
||||||
raise UnsafeFileError, "Unsafe sandbox file: #{basename}" unless before.file? && !before.symlink?
|
unless before.file? && !before.symlink? && before.nlink == 1
|
||||||
|
raise UnsafeFileError, "Unsafe sandbox file: #{basename}"
|
||||||
|
end
|
||||||
|
|
||||||
resolved = Pathname.new(path).realpath.to_s
|
resolved = Pathname.new(path).realpath.to_s
|
||||||
unless resolved.start_with?(sandbox_prefix)
|
unless resolved.start_with?(sandbox_prefix)
|
||||||
raise UnsafeFileError, "Sandbox file escapes its root: #{basename}"
|
raise UnsafeFileError, "Sandbox file escapes its root: #{basename}"
|
||||||
end
|
end
|
||||||
|
|
||||||
File.open(path, "rb") do |file|
|
flags = safe_open_flags
|
||||||
|
File.open(path, flags, encoding: Encoding::BINARY) do |file|
|
||||||
opened = file.stat
|
opened = file.stat
|
||||||
unless opened.file? && opened.dev == before.dev && opened.ino == before.ino
|
unless opened.file? && opened.nlink == 1 && opened.dev == before.dev && opened.ino == before.ino
|
||||||
raise UnsafeFileError, "Sandbox file changed while opening: #{basename}"
|
raise UnsafeFileError, "Sandbox file changed while opening: #{basename}"
|
||||||
end
|
end
|
||||||
if opened.size > @max_bytes
|
if opened.size > @max_bytes
|
||||||
@@ -102,8 +105,20 @@ module Opencode
|
|||||||
|
|
||||||
yield file
|
yield file
|
||||||
end
|
end
|
||||||
rescue Errno::ENOENT, Errno::ELOOP => e
|
rescue SystemCallError => e
|
||||||
raise UnsafeFileError, "Unsafe sandbox file #{basename}: #{e.message}"
|
raise UnsafeFileError, "Unsafe sandbox file #{basename}: #{e.message}"
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ Gem::Specification.new do |spec|
|
|||||||
# this gem builds on. During alpha both gems evolve in lockstep — we pin
|
# this gem builds on. During alpha both gems evolve in lockstep — we pin
|
||||||
# exactly (= not ~>) so that consumers always pick the version this gem
|
# exactly (= not ~>) so that consumers always pick the version this gem
|
||||||
# was tested against.
|
# was tested against.
|
||||||
spec.add_runtime_dependency "opencode-ruby", "= 0.0.1.alpha8"
|
spec.add_runtime_dependency "opencode-ruby", "= 0.0.1.alpha9"
|
||||||
spec.add_runtime_dependency "marcel", "~> 1.0"
|
spec.add_runtime_dependency "marcel", "~> 1.0"
|
||||||
|
|
||||||
# Rails sub-libraries used at runtime. Depending on these individually
|
# Rails sub-libraries used at runtime. Depending on these individually
|
||||||
|
|||||||
@@ -37,8 +37,35 @@ class ExampleTest < Minitest::Test
|
|||||||
|
|
||||||
def test_example_uses_current_fail_closed_permission_rules
|
def test_example_uses_current_fail_closed_permission_rules
|
||||||
refute_match(/\{\s*type:/, SOURCE)
|
refute_match(/\{\s*type:/, SOURCE)
|
||||||
assert_includes SOURCE, '{ permission: "bash", pattern: "*", action: "deny" }'
|
assert_includes SOURCE, 'sandbox_directory = sandbox_directory_for(conversation)'
|
||||||
assert_includes SOURCE, '{ permission: "external_directory", pattern: "*", action: "deny" }'
|
assert_includes SOURCE, 'directory: sandbox_directory.to_s'
|
||||||
assert_includes SOURCE, '{ permission: "edit", pattern: "*", action: "deny" }'
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
require "fileutils"
|
require "fileutils"
|
||||||
|
require "socket"
|
||||||
require "tmpdir"
|
require "tmpdir"
|
||||||
require "marcel" # SandboxFile#content_type uses Marcel::MimeType.for
|
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 }
|
assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content }
|
||||||
end
|
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
|
def test_content_rejects_an_inode_that_grows_past_the_cap_while_reading
|
||||||
file = Opencode::SandboxFile.new(
|
file = Opencode::SandboxFile.new(
|
||||||
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 20
|
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 20
|
||||||
@@ -98,6 +127,76 @@ class Opencode::SandboxFileTest < Minitest::Test
|
|||||||
end
|
end
|
||||||
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
|
def test_content_returns_binary_empty_string_for_a_zero_byte_file
|
||||||
File.write(@path, "")
|
File.write(@path, "")
|
||||||
file = Opencode::SandboxFile.new(
|
file = Opencode::SandboxFile.new(
|
||||||
|
|||||||
@@ -53,11 +53,21 @@ class ReadmeTest < Minitest::Test
|
|||||||
|
|
||||||
def test_quickstart_uses_current_fail_closed_permission_rules
|
def test_quickstart_uses_current_fail_closed_permission_rules
|
||||||
refute_match(/\{\s*type:/, @quickstart)
|
refute_match(/\{\s*type:/, @quickstart)
|
||||||
assert_includes @quickstart, '{ permission: "bash", pattern: "*", action: "deny" }'
|
assert_includes @quickstart, 'sandbox_directory = sandbox_directory_for(conversation)'
|
||||||
assert_includes @quickstart, '{ permission: "external_directory", pattern: "*", action: "deny" }'
|
assert_includes @quickstart, 'directory: sandbox_directory.to_s'
|
||||||
assert_includes @quickstart, '{ permission: "edit", pattern: "*", action: "deny" }'
|
assert_includes @quickstart, '{ permission: "*", pattern: "*", action: "deny" }'
|
||||||
assert_includes @quickstart, 'permission: "edit",'
|
refute_includes @quickstart, 'action: "allow"'
|
||||||
assert_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
|
end
|
||||||
|
|
||||||
def test_quickstart_uses_the_enqueued_user_message
|
def test_quickstart_uses_the_enqueued_user_message
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ class ReleaseWorkflowTest < Minitest::Test
|
|||||||
workflow.fetch("jobs").fetch("push")
|
workflow.fetch("jobs").fetch("push")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def verify_job
|
||||||
|
workflow.fetch("jobs").fetch("verify")
|
||||||
|
end
|
||||||
|
|
||||||
def test_release_job_is_inert_on_non_github_runners
|
def test_release_job_is_inert_on_non_github_runners
|
||||||
assert_equal "${{ github.server_url == 'https://github.com' }}", push_job.fetch("if")
|
assert_equal "${{ github.server_url == 'https://github.com' }}", push_job.fetch("if")
|
||||||
end
|
end
|
||||||
@@ -32,33 +36,41 @@ class ReleaseWorkflowTest < Minitest::Test
|
|||||||
setup_ruby = steps.find { |step| step["uses"] == SETUP_RUBY_ACTION }
|
setup_ruby = steps.find { |step| step["uses"] == SETUP_RUBY_ACTION }
|
||||||
|
|
||||||
assert_equal "4.0", setup_ruby.dig("with", "ruby-version")
|
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_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/) }
|
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
|
end
|
||||||
|
|
||||||
def test_release_tag_must_match_the_gem_version_before_publish
|
def test_release_tag_must_match_the_gem_version_before_publish
|
||||||
steps = push_job.fetch("steps")
|
preflight = verify_job.fetch("steps").find { |step| step["name"] == "Verify tag matches gem version" }
|
||||||
preflight_index = steps.index { |step| step["name"] == "Verify tag matches gem version" }
|
|
||||||
publish_index = steps.index { |step| step["uses"] == RELEASE_GEM_ACTION }
|
|
||||||
|
|
||||||
refute_nil preflight_index
|
refute_nil preflight
|
||||||
refute_nil publish_index
|
|
||||||
assert_operator preflight_index, :<, publish_index
|
|
||||||
|
|
||||||
preflight = steps.fetch(preflight_index)
|
|
||||||
assert_equal "${{ github.ref_name }}", preflight.dig("env", "RELEASE_TAG")
|
assert_equal "${{ github.ref_name }}", preflight.dig("env", "RELEASE_TAG")
|
||||||
assert_includes preflight.fetch("run"), "unless Opencode::RAILS_VERSION == expected"
|
assert_includes preflight.fetch("run"), "unless Opencode::RAILS_VERSION == expected"
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_release_runs_the_test_suite_before_publish
|
def test_verification_job_has_read_only_credentials
|
||||||
steps = push_job.fetch("steps")
|
assert_equal({ "contents" => "read" }, verify_job.fetch("permissions"))
|
||||||
test_index = steps.index { |step| step["name"] == "Run tests" }
|
|
||||||
publish_index = steps.index { |step| step["uses"] == RELEASE_GEM_ACTION }
|
|
||||||
|
|
||||||
refute_nil test_index
|
checkout = verify_job.fetch("steps").find { |step| step.fetch("uses", "").start_with?("actions/checkout@") }
|
||||||
refute_nil publish_index
|
assert_equal false, checkout.dig("with", "persist-credentials")
|
||||||
assert_operator test_index, :<, publish_index
|
end
|
||||||
assert_equal "bundle exec rake test", steps.fetch(test_index).fetch("run")
|
|
||||||
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class WorkflowContractTest < Minitest::Test
|
|||||||
workflow_uses(workflow)
|
workflow_uses(workflow)
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal 5, action_uses.length
|
assert_equal 7, action_uses.length
|
||||||
action_uses.each do |action_use|
|
action_uses.each do |action_use|
|
||||||
action, separator, revision = action_use.rpartition("@")
|
action, separator, revision = action_use.rpartition("@")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user