ace-lab 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+ require_relative "../molecules/install_manifest_loader"
5
+
6
+ module Ace
7
+ module Lab
8
+ module Auth
9
+ # Passive launch data from the installed manifest. Roles select a brief
10
+ # and a project-specific environment, not an operating-system identity.
11
+ class SessionConfiguration
12
+ ROLES = %w[lab-overseer project-overseer worker verifier integrator].freeze
13
+ Selection = Data.define(:project, :path, :role, :instructions, :gem_home, :runtime, :environment_file)
14
+
15
+ def initialize(loader: -> { Molecules::InstallManifestLoader.load })
16
+ @loader = loader
17
+ end
18
+
19
+ def resolve(project:, role:)
20
+ document = @loader.call
21
+ raise Error, "session role is not supported" unless ROLES.include?(role)
22
+
23
+ scope = if role == "lab-overseer"
24
+ document["coordinator"] if document.dig("coordinator", "id") == project
25
+ else
26
+ document.fetch("projects").find { |entry| entry["id"] == project }
27
+ end
28
+ selected_role = document.fetch("roles").find { |entry| entry["id"] == role }
29
+ raise Error, "session project or role is not configured" unless scope && selected_role
30
+
31
+ runtime = document.fetch("session_runtime")
32
+ raise Error, "session Pi runtime is not configured" unless runtime.is_a?(Hash)
33
+
34
+ Selection.new(project: project, path: scope.fetch("path"), role: role,
35
+ instructions: selected_role.fetch("instructions"),
36
+ gem_home: document.fetch("gemset").fetch("gem_home"), runtime: runtime,
37
+ environment_file: scope.fetch("environment_files", {})[role])
38
+ rescue KeyError, NoMethodError
39
+ raise Error, "session deployment configuration is incomplete"
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -19,7 +19,8 @@ module Ace
19
19
  "directories" => ->(item) { item["path"] },
20
20
  "keys" => ->(item) { "#{item['account']}:#{item['path']}" },
21
21
  "roles" => ->(item) { item["id"] },
22
- "gems" => ->(item) { "#{item['name']} #{item['version']}" }
22
+ "gems" => ->(item) { "#{item['name']} #{item['version']}" },
23
+ "extensions" => ->(item) { item["gem"] }
23
24
  }.freeze
24
25
 
25
26
  # "SHA256:" plus the first 20 base64 characters is enough to
@@ -16,7 +16,8 @@ module Ace
16
16
  "accounts" => "account", "directories" => "directory", "keys" => "account",
17
17
  "roles" => "id", "repositories" => "project", "grants" => "principal",
18
18
  "registry" => "project", "overseer" => "project",
19
- "adapters" => "adapter", "tools" => "tool", "gems" => "gem"
19
+ "adapters" => "adapter", "tools" => "tool", "gems" => "gem",
20
+ "extensions" => "gem"
20
21
  }.freeze
21
22
 
22
23
  desc "Verify the installed lab's readiness under the target accounts (read-only): " \
@@ -17,6 +17,59 @@ module Ace
17
17
 
18
18
  module_function
19
19
 
20
+ # Installed operational extensions must not sit below a directory
21
+ # that an agent can replace. The leaf may be created by the root
22
+ # installer, but every existing ancestor must already be protected.
23
+ def validate_protected_root!(path)
24
+ unless path.is_a?(String) && path.start_with?("/") && File.expand_path(path) == path && path != "/"
25
+ raise Ace::Lab::Error, "installed extension path must be a canonical absolute path"
26
+ end
27
+ current = "/"
28
+ root_stat = File.lstat(current)
29
+ unless root_stat.directory? && root_stat.uid.zero? && (root_stat.mode & 0o022).zero?
30
+ raise Ace::Lab::Error, "installed extension filesystem root is not root-controlled"
31
+ end
32
+ path.split("/").reject(&:empty?).each do |part|
33
+ current = File.join(current, part)
34
+ begin
35
+ stat = File.lstat(current)
36
+ rescue Errno::ENOENT
37
+ break
38
+ end
39
+ unless stat.directory? && !stat.symlink? && stat.uid.zero? && (stat.mode & 0o022).zero?
40
+ raise Ace::Lab::Error, "installed extension directory is not root-controlled: #{current}"
41
+ end
42
+ end
43
+ true
44
+ rescue SystemCallError => e
45
+ raise Ace::Lab::Error, "installed extension directory cannot be inspected (#{e.class})"
46
+ end
47
+
48
+ def validate_protected_files!(root, relative_paths, allow_missing: false)
49
+ validate_protected_root!(root)
50
+ relative_paths.each do |relative|
51
+ validate_destination!(root, relative)
52
+ current = root
53
+ segments = relative.split(File::SEPARATOR)
54
+ segments.each_with_index do |part, index|
55
+ current = File.join(current, part)
56
+ begin
57
+ stat = File.lstat(current)
58
+ rescue Errno::ENOENT
59
+ break if allow_missing
60
+ raise
61
+ end
62
+ kind_ok = index == segments.length - 1 ? stat.file? : stat.directory?
63
+ unless kind_ok && !stat.symlink? && stat.uid.zero? && (stat.mode & 0o022).zero?
64
+ raise Ace::Lab::Error, "installed extension path is not root-controlled: #{current}"
65
+ end
66
+ end
67
+ end
68
+ true
69
+ rescue SystemCallError => e
70
+ raise Ace::Lab::Error, "installed extension file cannot be inspected (#{e.class})"
71
+ end
72
+
20
73
  # The installed gem's own tree at the EXACT declared version.
21
74
  # @return [String, nil] the handbook/extensions directory or nil
22
75
  # when the gem (or its extensions tree) is not installed
@@ -30,10 +83,16 @@ module Ace
30
83
  # gem's handbook/extensions tree (e.g. "ace-wake/index.js").
31
84
  # @return [Hash<String, String>]
32
85
  def source_files(source_root)
33
- Dir.glob(File.join(source_root, "**", "*")).select { |path| File.file?(path) }
86
+ Dir.glob(File.join(source_root, "**", "*"), File::FNM_DOTMATCH).select { |path| File.file?(path) }
34
87
  .to_h { |path| [path.delete_prefix("#{source_root}/"), path] }
35
88
  end
36
89
 
90
+ def valid_receipt?(receipt)
91
+ receipt.is_a?(Hash) && receipt["files"].is_a?(Array) && receipt["files"].all? do |path|
92
+ path.is_a?(String) && !path.start_with?("/") && path.split("/", -1).all? { |segment| !segment.empty? && segment != "." && segment != ".." }
93
+ end
94
+ end
95
+
37
96
  # Resolve and fully validate a projection destination before
38
97
  # anything is written or verified (the provider_syncer
39
98
  # validate_projection_destination posture): every path
@@ -83,15 +83,28 @@ module Ace
83
83
  end
84
84
 
85
85
  output_dir = entry["path"]
86
+ if ENV["ACE_TOOLCHAIN"] == "installed"
87
+ begin
88
+ ExtensionProjection.validate_protected_files!(source, expected.keys)
89
+ ExtensionProjection.validate_protected_files!(output_dir,
90
+ expected.keys + [RECEIPT_NAME], allow_missing: true)
91
+ rescue Ace::Lab::Error => e
92
+ return failed(entry, version, e.message)
93
+ end
94
+ end
86
95
  escape = projection_escape_reason(output_dir, expected.keys)
87
96
  return failed(entry, version, escape) if escape
88
97
 
89
98
  receipt = read_receipt(output_dir)
99
+ if receipt
100
+ escape = projection_escape_reason(output_dir, receipt["files"])
101
+ return failed(entry, version, escape) if escape
102
+ end
90
103
  loaded_version = receipt && receipt["gem"] == entry["gem"] ? receipt["version"] : nil
91
104
 
92
105
  changed = projection_changed?(output_dir, expected, receipt, version)
93
106
  if !changed
94
- converge_modes(output_dir, expected, entry)
107
+ converge_modes(output_dir, expected, entry) unless @dry_run
95
108
  return entry_report(entry, version, "unchanged", loaded_version, false,
96
109
  receipt ? receipt.fetch("files") : expected.keys)
97
110
  end
@@ -157,6 +170,7 @@ module Ace
157
170
  # touched).
158
171
  previous = read_receipt(output_dir)&.fetch("files", []) || []
159
172
  retained = previous.filter_map do |relative|
173
+ ExtensionProjection.validate_destination!(output_dir, relative)
160
174
  target = File.join(output_dir, relative)
161
175
  next relative if expected.key?(relative)
162
176
 
@@ -175,22 +189,25 @@ module Ace
175
189
  # so the target executor can always read its extensions.
176
190
  def converge_modes(output_dir, expected, entry)
177
191
  account = @account_lookup.call(entry["account"])
192
+ owner = ENV["ACE_TOOLCHAIN"] == "installed" ? [0, 0] : (account && [account.uid, account.gid])
178
193
  targets = expected.keys.map { |relative| File.join(output_dir, relative) }
179
194
  targets << File.join(output_dir, RECEIPT_NAME)
180
195
  targets.each do |target|
181
196
  next unless File.file?(target)
182
197
 
183
198
  FileUtils.chmod(FILE_MODE, target)
184
- FileUtils.chown(account.uid, account.gid, target) if account
199
+ FileUtils.chown(*owner, target) if owner
185
200
  end
186
201
  root = File.realpath(output_dir)
202
+ FileUtils.chmod(DIR_MODE, root)
203
+ FileUtils.chown(*owner, root) if owner
187
204
  dirs = expected.keys.filter_map do |relative|
188
205
  File.dirname(File.join(root, relative)) if relative.include?(File::SEPARATOR)
189
206
  end.uniq
190
207
  until dirs.empty?
191
208
  dir = dirs.pop
192
209
  FileUtils.chmod(DIR_MODE, dir)
193
- FileUtils.chown(account.uid, account.gid, dir) if account
210
+ FileUtils.chown(*owner, dir) if owner
194
211
  parent = File.dirname(dir)
195
212
  dirs << parent if dir != root && parent.start_with?("#{root}/") && !dirs.include?(parent) && parent != root
196
213
  end
@@ -210,7 +227,7 @@ module Ace
210
227
  return nil unless File.file?(path)
211
228
 
212
229
  document = JSON.parse(File.read(path))
213
- document.is_a?(Hash) ? document : nil
230
+ ExtensionProjection.valid_receipt?(document) ? document : nil
214
231
  rescue JSON::ParserError, Errno::EACCES
215
232
  nil
216
233
  end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "rbconfig"
6
+ require "find"
7
+
8
+ module Ace
9
+ module Lab
10
+ module Molecules
11
+ # Installed specifications select identity; cached gem archives own payload
12
+ # membership (installed gemspecs omit files). No recursive runtime sweep.
13
+ class GemPayloadPermissions
14
+ class Error < StandardError; end
15
+ INVENTORY = <<~'CODE'
16
+ require 'json'
17
+ require 'rubygems'
18
+ require 'rubygems/package'
19
+ home = ARGV.shift
20
+ Gem.use_paths(home)
21
+ queue = [Gem::Specification.find_by_name(ARGV.shift, ARGV.shift)]
22
+ dry_run = ARGV.shift == "dry-run"
23
+ require_complete = ARGV.shift == "complete"
24
+ seen = {}; rows = []
25
+ until queue.empty?
26
+ spec = queue.shift
27
+ next if seen[spec.full_name]
28
+ seen[spec.full_name] = true
29
+ unless spec.default_gem?
30
+ cache = File.join(home, "cache", "#{spec.full_name}.gem")
31
+ unless File.file?(cache) && !File.symlink?(cache) && !File.symlink?(File.dirname(cache))
32
+ puts JSON.generate(error: "installed gem payload cache missing or symlinked: #{cache}")
33
+ exit 1
34
+ end
35
+ package = Gem::Package.new(cache)
36
+ package.verify
37
+ unless package.spec.full_name == spec.full_name
38
+ puts JSON.generate(error: "installed gem payload cache identity mismatch: #{cache}")
39
+ exit 1
40
+ end
41
+ rows << {root: spec.full_gem_path, spec: spec.loaded_from,
42
+ files: package.contents, executables: spec.executables,
43
+ bindir: spec.bindir, extension: spec.extensions.empty? ? nil : spec.extension_dir}
44
+ end
45
+ spec.runtime_dependencies.each do |dep|
46
+ begin
47
+ queue << dep.to_spec
48
+ rescue Gem::MissingSpecError
49
+ # Convergence already predicts installing absent dependencies.
50
+ # Validate the existing payload; do not invent future files.
51
+ raise unless dry_run && !require_complete
52
+ end
53
+ end
54
+ end
55
+ puts JSON.generate(rows)
56
+ CODE
57
+
58
+ def initialize(gem_home:, dry_run:, privileged:, require_complete: false)
59
+ @declared_home = File.expand_path(gem_home)
60
+ raise Error, "gem runtime is a symlink" if File.symlink?(@declared_home)
61
+ @home = File.realpath(@declared_home)
62
+ @dry_run, @privileged = dry_run, privileged
63
+ @require_complete = require_complete
64
+ end
65
+
66
+ def call(name:, version:)
67
+ # Default Ruby gems need no mutation in this runtime. With no local
68
+ # specifications there cannot be any owned installed payload here.
69
+ unless File.directory?(File.join(@home, "specifications"))
70
+ raise Error, "installed gem specifications are missing" if @require_complete
71
+ return []
72
+ end
73
+
74
+ env = ENV.keys.grep(/\ABUNDLE/).to_h { |key| [key, nil] }
75
+ env.merge!("RUBYOPT" => nil, "RUBYLIB" => nil, "RUBYGEMS_GEMDEPS" => nil, "GEM_SPEC_CACHE" => nil,
76
+ "LD_PRELOAD" => nil, "LD_LIBRARY_PATH" => nil, "LD_AUDIT" => nil, "GEM_HOME" => @home, "GEM_PATH" => @home)
77
+ out, _err, status = Open3.capture3(env, RbConfig.ruby, "-e", INVENTORY, @home, name, version,
78
+ @dry_run ? "dry-run" : "apply", @require_complete ? "complete" : "partial")
79
+ unless status.success?
80
+ detail = begin
81
+ JSON.parse(out)["error"]
82
+ rescue JSON::ParserError
83
+ nil
84
+ end
85
+ raise Error, detail || "could not inventory installed gem payload #{name} #{version}"
86
+ end
87
+
88
+ converge(JSON.parse(out))
89
+ rescue SystemCallError, JSON::ParserError => e
90
+ raise Error, "gem payload permission convergence failed (#{e.class})"
91
+ end
92
+
93
+ def converge(rows)
94
+ paths = {}
95
+ executables = []
96
+ rows.each do |row|
97
+ root = validate(row.fetch("root"))
98
+ raise Error, "invalid installed gem root" unless File.dirname(root) == File.join(@home, "gems")
99
+ spec = validate(row.fetch("spec"))
100
+ raise Error, "invalid installed gem specification" unless File.dirname(spec) == File.join(@home, "specifications")
101
+ add(paths, spec)
102
+ row.fetch("files").each { |file| add(paths, payload_path(root, file)) }
103
+ row.fetch("executables").each do |exe|
104
+ raise Error, "invalid gem executable" unless File.basename(exe) == exe && !%w[. ..].include?(exe)
105
+ [payload_path(root, File.join(row.fetch("bindir"), exe)), File.join(@home, "bin", exe)].each do |path|
106
+ add(paths, path)
107
+ executables << validate(path)
108
+ end
109
+ end
110
+ if (extension = row["extension"])
111
+ extension = validate(extension)
112
+ raise Error, "invalid gem extension directory" unless
113
+ extension.start_with?(File.join(@home, "extensions") + "/") &&
114
+ File.basename(extension) == File.basename(root)
115
+ Find.find(extension) do |path|
116
+ validate(path)
117
+ add(paths, path)
118
+ end
119
+ end
120
+ end
121
+ changes = paths.filter_map do |path, stat|
122
+ desired = stat.directory? || executables.include?(path) || (stat.mode & 0o111).positive? ? 0o755 : 0o644
123
+ owner_change = @privileged && (stat.uid != 0 || stat.gid != 0)
124
+ next if (stat.mode & 0o7777) == desired && !owner_change
125
+ {"path" => path, "mode" => desired, "owner_change" => owner_change,
126
+ "status" => @dry_run ? "would_normalize" : "normalized"}
127
+ end
128
+ # Inventory validation is complete before the first mutation.
129
+ unless @dry_run
130
+ changes.each do |change|
131
+ path = validate(change.fetch("path"))
132
+ current = File.lstat(path)
133
+ original = paths.fetch(path)
134
+ raise Error, "gem payload changed during permission convergence: #{path}" unless
135
+ current.ino == original.ino && current.dev == original.dev && !current.symlink?
136
+ File.chown(0, 0, path) if change["owner_change"]
137
+ File.chmod(change.fetch("mode"), path)
138
+ end
139
+ end
140
+ changes
141
+ end
142
+
143
+ private
144
+
145
+ def payload_path(root, relative)
146
+ path = File.expand_path(relative, root)
147
+ raise Error, "gem payload escapes specification root" unless path.start_with?(root + "/")
148
+ path
149
+ end
150
+
151
+ def validate(path)
152
+ path = File.expand_path(path)
153
+ if path == @declared_home || path.start_with?(@declared_home + "/")
154
+ path = @home + path.delete_prefix(@declared_home)
155
+ end
156
+ raise Error, "gem payload escapes declared runtime: #{path}" unless path == @home || path.start_with?(@home + "/")
157
+ cursor = path
158
+ loop do
159
+ stat = File.lstat(cursor)
160
+ raise Error, "gem payload symlink refused: #{cursor}" if stat.symlink?
161
+ raise Error, "unsupported gem payload: #{cursor}" unless stat.file? || stat.directory?
162
+ break if cursor == @home
163
+ cursor = File.dirname(cursor)
164
+ end
165
+ path
166
+ end
167
+
168
+ def add(paths, path)
169
+ path = validate(path)
170
+ loop do
171
+ stat = File.lstat(path)
172
+ raise Error, "hardlinked gem payload refused: #{path}" if stat.file? && stat.nlink > 1
173
+ paths[path] = stat
174
+ break if path == @home
175
+ path = File.dirname(path)
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "../errors"
4
4
  require "open3"
5
5
  require "fileutils"
6
+ require_relative "gem_payload_permissions"
6
7
 
7
8
  module Ace
8
9
  module Lab
@@ -210,7 +211,12 @@ module Ace
210
211
  closure = converge_closure(gem, gem_home)
211
212
  return report.merge("status" => "failed", "message" => closure) if closure.is_a?(String)
212
213
  report["closure"] = closure
214
+ permissions = GemPayloadPermissions.new(gem_home: gem_home, dry_run: @dry_run,
215
+ privileged: @privileged.call).call(name: gem["name"], version: gem["version"])
216
+ report["permissions"] = permissions
213
217
  report
218
+ rescue GemPayloadPermissions::Error => e
219
+ report.merge("status" => "failed", "message" => e.message)
214
220
  end
215
221
 
216
222
  # @return [Array<Hash>, String] per-version removal reports or a
@@ -328,8 +334,13 @@ module Ace
328
334
 
329
335
  def satisfying_versions(dep, versions)
330
336
  requirement = dep["requirement"].split(",").map(&:strip)
337
+ constraint = Gem::Requirement.new(*requirement)
331
338
  versions.select do |version|
332
- Gem::Dependency.new(dep["name"], *requirement).match?(dep["name"], version)
339
+ # RubyGems can activate an explicitly installed prerelease even
340
+ # when the depending gem uses a normal version requirement.
341
+ # Dependency#match? filters prereleases intended for discovery;
342
+ # closure checks must describe what this installed runtime can use.
343
+ constraint.satisfied_by?(Gem::Version.new(version))
333
344
  end
334
345
  end
335
346
 
@@ -9,6 +9,7 @@ require_relative "../molecules/extension_provisioner"
9
9
  require_relative "../molecules/hitl_routes"
10
10
  require_relative "../molecules/caller_authorizer"
11
11
  require_relative "../atoms/topology_schema"
12
+ require "ace/support/cli/toolchain"
12
13
  require "open3"
13
14
  require "yaml"
14
15
  require "json"
@@ -199,9 +200,8 @@ module Ace
199
200
  # verified readiness. Protocol instructions are resolved with
200
201
  # ace-nav from every declared project root (the execution
201
202
  # contexts the executor works in); a role that fails resolution
202
- # is a failed check, an environment without the resolver
203
- # reports unknown (offline verifier posture, like network
204
- # remotes) and does not block.
203
+ # is a failed check. In auto mode a missing resolver reports unknown;
204
+ # installed mode requires the installed executable and fails closed.
205
205
  def check_roles(manifest)
206
206
  verifier = Molecules::RoleVerifier.new(
207
207
  account_lookup: @account_lookup, group_source: @group_source
@@ -252,6 +252,8 @@ module Ace
252
252
  "'#{instructions}'; not probed by the read-only verifier"] if unresolved_resolver
253
253
 
254
254
  [PASS, nil]
255
+ rescue Ace::Support::Cli::Toolchain::Error => e
256
+ [FAIL, "ACE toolchain error: #{e.message}"]
255
257
  end
256
258
 
257
259
  def resolution_error(result)
@@ -259,20 +261,18 @@ module Ace
259
261
  detail ? detail : "ace-nav exited with status #{result['status']}"
260
262
  end
261
263
 
262
- # Same binary choice the executor's resolver makes
263
- # (Ace::Coworker::Molecules::InstructionResolver): the
264
- # project's binstub when present, otherwise the PATH lookup.
265
- # A privileged installer never executes repository-controlled
266
- # binstubs (same trust posture as the LAB-1 symlink refusals):
267
- # under root only the PATH-installed resolver is used.
264
+ # Match the executor's resolver: installed mode always resolves the
265
+ # installed launcher, and root never executes repository binstubs.
268
266
  def nav_binary(project_root)
267
+ return Ace::Support::Cli::Toolchain.executable("ace-nav") if Ace::Support::Cli::Toolchain.installed?
268
+
269
269
  binstub = File.join(project_root, "bin", "ace-nav")
270
270
  use_binstub = !Process.uid.zero? && File.executable?(binstub)
271
271
  use_binstub ? binstub : "ace-nav"
272
272
  end
273
273
 
274
274
  def run_nav(argv, cwd)
275
- out, err, status = Open3.capture3(*argv, chdir: cwd)
275
+ out, err, status = Open3.capture3(Ace::Support::Cli::Toolchain.child_environment, *argv, chdir: cwd)
276
276
  {"status" => status.success? ? "ok" : (status.exitstatus || "signal #{status.termsig}"),
277
277
  "out" => out, "err" => err}
278
278
  rescue Errno::ENOENT
@@ -577,7 +577,12 @@ module Ace
577
577
 
578
578
  gem_home = gemset["gem_home"]
579
579
  runtime_reason = gem_runtime_reason(gem_home)
580
- provisioner = Molecules::GemsetProvisioner.new(runner: @runner)
580
+ # All probes below are read-only and overlap heavily across root
581
+ # dependency graphs. Reuse identical command results only within
582
+ # this verification pass; a later call always reads the runtime again.
583
+ probes = {}
584
+ read_once = ->(argv) { probes.fetch(argv) { probes[argv] = @runner.call(argv) } }
585
+ provisioner = Molecules::GemsetProvisioner.new(runner: read_once)
581
586
  gems.map do |gem|
582
587
  if runtime_reason
583
588
  next {"gem" => gem["name"], "version" => gem["version"], "gem_home" => gem_home,
@@ -657,8 +662,17 @@ module Ace
657
662
  gem_home, entry["gem"], declared["version"]
658
663
  )
659
664
  return "gem '#{entry["gem"]}' #{declared["version"]} is not installed with a " \
660
- "handbook/extensions tree in '#{gem_home}'; rerun ace-lab install" unless File.directory?(source_root)
661
-
665
+ "handbook/extensions tree in '#{gem_home}'; rerun ace-lab install" if source_root.nil?
666
+
667
+ if ENV["ACE_TOOLCHAIN"] == "installed"
668
+ begin
669
+ Molecules::ExtensionProjection.validate_protected_root!(source_root)
670
+ Molecules::ExtensionProjection.validate_protected_files!(entry["path"],
671
+ [Molecules::ExtensionProjection::RECEIPT_NAME])
672
+ rescue Ace::Lab::Error => e
673
+ return "#{e.message}; rerun ace-lab install"
674
+ end
675
+ end
662
676
  receipt_path = File.join(entry["path"], Molecules::ExtensionProvisioner::RECEIPT_NAME)
663
677
  unless File.file?(receipt_path)
664
678
  return "extension projection receipt '#{receipt_path}' is missing; rerun ace-lab install"
@@ -672,6 +686,9 @@ module Ace
672
686
  return "extension projection receipt '#{receipt_path}' is not readable; " \
673
687
  "rerun ace-lab install"
674
688
  end
689
+ unless Molecules::ExtensionProjection.valid_receipt?(receipt)
690
+ return "extension projection receipt '#{receipt_path}' has invalid files; rerun ace-lab install"
691
+ end
675
692
  if receipt["gem"] != entry["gem"] || receipt["version"] != declared["version"]
676
693
  return "extension projection at '#{entry["path"]}' was projected from " \
677
694
  "'#{receipt["gem"]} #{receipt["version"]}' but the manifest declares " \
@@ -692,6 +709,11 @@ module Ace
692
709
  expected.each_key do |relative|
693
710
  Molecules::ExtensionProjection.validate_destination!(entry["path"], relative)
694
711
  end
712
+ if ENV["ACE_TOOLCHAIN"] == "installed"
713
+ Molecules::ExtensionProjection.validate_protected_files!(source_root, expected.keys)
714
+ Molecules::ExtensionProjection.validate_protected_files!(entry["path"],
715
+ expected.keys + [Molecules::ExtensionProjection::RECEIPT_NAME])
716
+ end
695
717
  rescue Ace::Lab::Error => e
696
718
  return "#{e.message}; rerun ace-lab install"
697
719
  end
@@ -713,7 +735,28 @@ module Ace
713
735
  return "extension projection file '#{unreadable}' is not readable by the declared " \
714
736
  "account '#{entry["account"]}'; rerun ace-lab install" if unreadable
715
737
 
738
+ directories = [entry["path"]] + expected.keys.flat_map do |relative|
739
+ segments = relative.split("/")[0...-1]
740
+ segments.each_index.map { |i| File.join(entry["path"], *segments[0..i]) }
741
+ end
742
+ if ENV["ACE_TOOLCHAIN"] == "installed"
743
+ parent = File.dirname(entry["path"])
744
+ loop do
745
+ directories << parent
746
+ break if parent == "/"
747
+ parent = File.dirname(parent)
748
+ end
749
+ end
750
+ inaccessible = directories.uniq.find do |dir|
751
+ stat = lstat(dir)
752
+ !stat || !stat.directory? || stat.symlink? ||
753
+ ((stat.mode & 0o001).zero? && !((stat.mode & 0o100) != 0 && account_entry && stat.uid == account_entry.uid))
754
+ end
755
+ return "extension projection directory '#{inaccessible}' is not traversable by the declared account '#{entry["account"]}'" if inaccessible
756
+
716
757
  nil
758
+ rescue SystemCallError => e
759
+ "extension projection at '#{entry["path"]}' cannot be inspected (#{e.class.name}); rerun ace-lab install"
717
760
  end
718
761
 
719
762
  def unreadable_reason(path, account_entry)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Lab
5
- VERSION = "0.14.0"
5
+ VERSION = "0.15.0"
6
6
  end
7
7
  end
@@ -0,0 +1,10 @@
1
+ #!/bin/sh
2
+ # Executable code belongs to the protected installed package, never the
3
+ # ephemeral credential directory (which may be mounted noexec).
4
+ if [ "$1" = "Username for '$ACE_LAB_FORGEJO_ORIGIN': " ]; then
5
+ printf '%s\n' "$ACE_LAB_FORGEJO_ACCOUNT"
6
+ elif [ "$1" = "Password for '$ACE_LAB_FORGEJO_SCHEME://$ACE_LAB_FORGEJO_ACCOUNT@$ACE_LAB_FORGEJO_AUTHORITY': " ]; then
7
+ cat "$ACE_LAB_CREDENTIAL_FILE"
8
+ else
9
+ exit 1
10
+ fi
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-lab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.0
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-30 00:00:00.000000000 Z
10
+ date: 2026-11-10 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -58,6 +58,9 @@ dependencies:
58
58
  - - "~>"
59
59
  - !ruby/object:Gem::Version
60
60
  version: '0.7'
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: 0.9.0
61
64
  type: :runtime
62
65
  prerelease: false
63
66
  version_requirements: !ruby/object:Gem::Requirement
@@ -65,6 +68,9 @@ dependencies:
65
68
  - - "~>"
66
69
  - !ruby/object:Gem::Version
67
70
  version: '0.7'
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: 0.9.0
68
74
  - !ruby/object:Gem::Dependency
69
75
  name: ace-support-test-helpers
70
76
  requirement: !ruby/object:Gem::Requirement
@@ -175,13 +181,16 @@ files:
175
181
  - lib/ace/lab/atoms/topology_schema.rb
176
182
  - lib/ace/lab/auth.rb
177
183
  - lib/ace/lab/auth/credential_handoff.rb
184
+ - lib/ace/lab/auth/error.rb
178
185
  - lib/ace/lab/auth/lease_store.rb
186
+ - lib/ace/lab/auth/local_command.rb
179
187
  - lib/ace/lab/auth/plugins/env_provider.rb
180
188
  - lib/ace/lab/auth/plugins/forgejo_provider.rb
181
189
  - lib/ace/lab/auth/plugins/github_provider.rb
182
190
  - lib/ace/lab/auth/profile_schema.rb
183
191
  - lib/ace/lab/auth/providers.rb
184
192
  - lib/ace/lab/auth/service.rb
193
+ - lib/ace/lab/auth/session_configuration.rb
185
194
  - lib/ace/lab/authorization_path.rb
186
195
  - lib/ace/lab/cli.rb
187
196
  - lib/ace/lab/cli/commands/agents.rb
@@ -212,6 +221,7 @@ files:
212
221
  - lib/ace/lab/molecules/extension_provisioner.rb
213
222
  - lib/ace/lab/molecules/forge_merge_config.rb
214
223
  - lib/ace/lab/molecules/forgejo_merge_adapter.rb
224
+ - lib/ace/lab/molecules/gem_payload_permissions.rb
215
225
  - lib/ace/lab/molecules/gemset_provisioner.rb
216
226
  - lib/ace/lab/molecules/grant_provisioner.rb
217
227
  - lib/ace/lab/molecules/grant_resolver.rb
@@ -233,6 +243,7 @@ files:
233
243
  - lib/ace/lab/organisms/topology_service.rb
234
244
  - lib/ace/lab/organisms/verify.rb
235
245
  - lib/ace/lab/version.rb
246
+ - libexec/forgejo-askpass
236
247
  homepage: https://github.com/cs3b/ace
237
248
  licenses:
238
249
  - MIT