ace-lab 0.7.0 → 0.9.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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/docs/usage.md +343 -0
  4. data/lib/ace/lab/cli/commands/assignment.rb +92 -0
  5. data/lib/ace/lab/cli/commands/command_execute.rb +28 -0
  6. data/lib/ace/lab/cli/commands/run.rb +65 -0
  7. data/lib/ace/lab/cli/commands/service.rb +12 -28
  8. data/lib/ace/lab/cli.rb +16 -1
  9. data/lib/ace/lab/molecules/command_owner_admission.rb +183 -0
  10. data/lib/ace/lab/molecules/command_policy.rb +158 -0
  11. data/lib/ace/lab/molecules/grant_resolver.rb +15 -0
  12. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +20 -1
  13. data/lib/ace/lab/molecules/ordinary_listener.rb +41 -17
  14. data/lib/ace/lab/molecules/ordinary_merge_read.rb +45 -0
  15. data/lib/ace/lab/molecules/ordinary_native_read.rb +106 -0
  16. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +152 -0
  17. data/lib/ace/lab/molecules/ordinary_owner_config.rb +232 -0
  18. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +12 -4
  19. data/lib/ace/lab/molecules/ordinary_socket.rb +6 -9
  20. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +216 -0
  21. data/lib/ace/lab/molecules/service_executor.rb +9 -39
  22. data/lib/ace/lab/molecules/service_policy.rb +15 -4
  23. data/lib/ace/lab/organisms/authority_composition.rb +1 -1
  24. data/lib/ace/lab/organisms/command_executor.rb +220 -0
  25. data/lib/ace/lab/organisms/command_run_client.rb +131 -0
  26. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +120 -0
  27. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +153 -0
  28. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +77 -61
  29. data/lib/ace/lab/organisms/ordinary_merge_request.rb +41 -7
  30. data/lib/ace/lab/version.rb +1 -1
  31. metadata +33 -6
  32. data/lib/ace/lab/molecules/ordinary_ff_merge.rb +0 -86
@@ -7,6 +7,7 @@ require_relative "support"
7
7
  require_relative "../../organisms/protected_service_request"
8
8
  require_relative "../../organisms/ordinary_merge_request"
9
9
  require_relative "../../organisms/ordinary_merge_receiver"
10
+ require_relative "../../organisms/ordinary_authority_composition"
10
11
  require_relative "../../molecules/ordinary_listener"
11
12
  require_relative "../../molecules/ordinary_receiver_config"
12
13
 
@@ -176,7 +177,7 @@ module Ace
176
177
  # The ordinary receiver serve entrypoint (spec 8x3.t.xz9.4
177
178
  # execution clauses; 8x3.t.xz9.5 integration handoff): binds the
178
179
  # configured unix socket, mutually authenticates every connection
179
- # through SO_PEERCRED, admits through the canonical Assign
180
+ # through OS peer credentials, admits through the canonical Assign
180
181
  # coordinator, executes the authorized fixed operation — one REAL
181
182
  # Forgejo merge under the scoped host-installed service identity —
182
183
  # and settles the exact terminal receipt proven by real forge
@@ -188,7 +189,7 @@ module Ace
188
189
  include Ace::Support::Cli::Base
189
190
  include Output
190
191
 
191
- desc "Serve the ordinary fixed merge receiver on its configured socket"
192
+ desc "Serve ordinary Assign, HITL and fixed merge under the installed journal owner"
192
193
  option :mode, type: :string, required: true,
193
194
  desc: "ordinary-merge selects the ordinary fixed merge receiver"
194
195
  option :project, type: :string, required: true, desc: "Project stable ID"
@@ -217,36 +218,19 @@ module Ace
217
218
  request_id: "unknown", input_digest: nil
218
219
  ))
219
220
  end
220
- policy_document = Molecules::GrantResolver.trusted_document(
221
- receiver_config.policy_path
222
- )
223
- # The ONE canonical Assign journal: the coordinator binds the
224
- # evidence ref of the canonical repository the receiver is
225
- # installed for (config repo_path — the producer project's
226
- # canonical checkout, the same authoritative journal the
227
- # producer coordinator uses). Never an integration-checkout
228
- # duplicate; the service identity's reviewed permissions are
229
- # journal ref read/append, evidence checkout and no-effect
230
- # attestation writes inside that repository.
231
- coordinator = Ace::Assign::Organisms::AttemptCoordinator.new(
232
- repo_root: receiver_config.repo_path
233
- )
234
- receiver = Organisms::OrdinaryMergeReceiver.new(
235
- config: receiver_config, coordinator: coordinator,
236
- policy_document: policy_document
237
- )
238
- listener = Molecules::OrdinaryListener.new(
239
- socket_path: receiver_config.socket_path,
240
- peer_uid: receiver_config.principal_uid,
241
- handler: receiver
242
- )
243
- receiver.recover_pending!
244
- listener.serve
245
- rescue Ace::Lab::InvalidConfigurationError, Ace::Assign::AttemptErrors::EvidenceUnavailable => e
221
+ unless receiver_config.receiver_uid == Process.uid
222
+ raise Ace::Lab::InvalidConfigurationError, "receiver must run as its configured UID"
223
+ end
224
+ server = Organisms::OrdinaryAuthorityComposition.new(receiver_config: receiver_config).build
225
+ signals = %w[INT TERM].to_h { |signal| [signal, Signal.trap(signal) { server.request_stop }] }
226
+ server.serve
227
+ rescue Ace::Lab::InvalidConfigurationError, Ace::Assign::Error, Ace::Runtime::Error, Ace::Hitl::Lifecycle::Error => e
246
228
  emit(Organisms::OrdinaryMergeRequest.refusal_envelope(
247
229
  "ordinary_merge_unavailable", e.message,
248
230
  request_id: "unknown", input_digest: nil
249
231
  ))
232
+ ensure
233
+ signals&.each { |signal, handler| Signal.trap(signal, handler) }
250
234
  end
251
235
  end
252
236
  end
data/lib/ace/lab/cli.rb CHANGED
@@ -9,6 +9,9 @@ require_relative "cli/commands/services"
9
9
  require_relative "cli/commands/resolve"
10
10
  require_relative "cli/commands/route"
11
11
  require_relative "cli/commands/service"
12
+ require_relative "cli/commands/assignment"
13
+ require_relative "cli/commands/command_execute"
14
+ require_relative "cli/commands/run"
12
15
 
13
16
  module Ace
14
17
  module Lab
@@ -20,6 +23,8 @@ module Ace
20
23
 
21
24
  # Application commands with descriptions (for help output)
22
25
  REGISTERED_COMMANDS = [
26
+ ["run", "Run one explicitly authorized project command"],
27
+ ["run-status", "Inspect an original command execution without running it"],
23
28
  ["projects", "List lab projects visible to the verified caller"],
24
29
  ["agents", "List lab agents in a project by stable ID"],
25
30
  ["services", "List lab services in a project by stable ID"],
@@ -27,7 +32,10 @@ module Ace
27
32
  ["route", "Select a configured capable service in a project"],
28
33
  ["service request", "Request one authorized service operation"],
29
34
  ["service status", "Read a service request outcome"],
30
- ["service listen", "Serve the ordinary fixed merge receiver on its configured socket"]
35
+ ["service listen", "Serve the ordinary fixed merge receiver on its configured socket"],
36
+ ["assignment prepare", "Retain reviewed task input for ordinary Assign registration"],
37
+ ["assignment drive", "Drive one original execution through the installed ordinary owner"],
38
+ ["assignment request", "Send one retained operation to the installed ordinary Assign owner"]
31
39
  ].freeze
32
40
 
33
41
  HELP_EXAMPLES = [
@@ -42,6 +50,7 @@ module Ace
42
50
  # @param args [Array<String>] Command-line arguments
43
51
  # @return [Integer] Exit code (0 for success, non-zero for failure)
44
52
  def self.start(args)
53
+ return CLI::Commands::Run.invoke(args.drop(1)) if args.first == "run" && !(args.take(args.index("--") || args.size).include?("--help") || args.take(args.index("--") || args.size).include?("-h"))
45
54
  Ace::Support::Cli::Runner.new(self).call(args: args)
46
55
  end
47
56
 
@@ -54,6 +63,12 @@ module Ace
54
63
  register "service request", CLI::Commands::Service::Request.new
55
64
  register "service status", CLI::Commands::Service::Status.new
56
65
  register "service listen", CLI::Commands::Service::Listen.new
66
+ register "run", CLI::Commands::Run.new
67
+ register "run-status", CLI::Commands::RunStatus.new
68
+ register "command execute", CLI::Commands::CommandExecute.new
69
+ register "assignment prepare", CLI::Commands::Assignment::Prepare.new
70
+ register "assignment drive", CLI::Commands::Assignment::Drive.new
71
+ register "assignment request", CLI::Commands::Assignment::Request.new
57
72
 
58
73
  # Register version command
59
74
  version_cmd = Ace::Support::Cli::VersionCommand.build(
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "find"
4
+ require "ace/assign/authority/command_execution"
5
+ require "ace/herdr/molecules/bounded_observation"
6
+ require "ace/runtime/molecules/process_identity"
7
+ require_relative "command_policy"
8
+
9
+ module Ace
10
+ module Lab
11
+ module Molecules
12
+ # Adds the project-command principals to the same installed listener.
13
+ # Root launch facts do not impersonate a participant/native agent.
14
+ class CommandOwnerAdmission
15
+ def initialize(base:, project:, policy_loader: nil)
16
+ @base, @project = base, project
17
+ @policy_loader = policy_loader || -> { CommandPolicy.load(project: @project) }
18
+ @peer = Ace::Assign::Authority::OrdinaryPeer.new
19
+ end
20
+ def service = @base.service
21
+ def verify_listener! = @base.verify_listener!
22
+ def peer(socket, deadline:) = @base.peer(socket, deadline: deadline)
23
+ def authorize!(request:, peer:, deadline:)
24
+ return @base.authorize!(request: request, peer: peer, deadline: deadline) unless Ace::Assign::Authority::CommandExecution::OPERATIONS.include?(request["operation"])
25
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity unless request["project_id"] == @project
26
+ if %w[command_claim command_bind command_complete].include?(request["operation"])
27
+ command_launcher!(peer: peer, deadline: deadline)
28
+ elsif request["operation"] == "command_status"
29
+ @peer.live!(peer, deadline: deadline) # Handler checks original requester before exposing data.
30
+ else
31
+ command_reader!(peer: peer, deadline: deadline)
32
+ end
33
+ :worker
34
+ end
35
+
36
+ def command_reader!(peer:, deadline:, definition: nil)
37
+ @peer.live!(peer, deadline: deadline)
38
+ return true if peer["uid"] == 0 && command_launcher!(peer: peer, deadline: deadline)
39
+ if definition
40
+ # Inspection never grants a second effect. Original requester UID
41
+ # retains reconciliation after policy revocation or process restart.
42
+ unless definition.dig("requester", "uid") == peer.fetch("uid")
43
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "command reference belongs to another requester"
44
+ end
45
+ return true
46
+ end
47
+ # Authorize visibility through the same closed selection machinery;
48
+ # absence of any configured grant is a refusal, not native inference.
49
+ document = policy
50
+ document.reader!(peer.fetch("uid"))
51
+ end
52
+
53
+ def command_request!(selection:, peer:, deadline:)
54
+ @peer.live!(peer, deadline: deadline)
55
+ selected = policy.select(selection: selection, peer: peer)
56
+ if selected.fetch("requester_kind") == "agent"
57
+ selected["native_binding"] = @base.identity!(peer: peer, pane: selection.fetch("pane"), deadline: deadline).runtime_binding
58
+ end
59
+ verify_context!(selected, deadline)
60
+ selected
61
+ end
62
+
63
+ # Historical execution can outlive its requester. Current root policy
64
+ # and immutable source/executable must still grant this exact TEST
65
+ # command; requester liveness does not invent a new execution.
66
+ def command_test_evidence!(definition:, deadline:)
67
+ unless definition["purpose"] == "test" && definition.dig("source", "head") && definition.dig("source", "tree") &&
68
+ policy.select(selection: definition.fetch("selection"), peer: definition.fetch("requester")) == definition.except("native_binding")
69
+ raise Ace::Assign::AttemptErrors::ReceiptRejected, "executed test command policy was revoked or changed"
70
+ end
71
+ verify_context!(definition, deadline)
72
+ rescue SecurityError, Ace::Lab::InvalidConfigurationError
73
+ raise Ace::Assign::AttemptErrors::ReceiptRejected, "executed test command policy or source is unavailable"
74
+ end
75
+
76
+ def command_launcher!(peer:, deadline:)
77
+ @peer.live!(peer, deadline: deadline)
78
+ expected = JSON.parse(GrantResolver.read_verified(policy.launcher.fetch("identity_path")) || "null")
79
+ unless expected.is_a?(Hash) && expected.keys.sort == %w[identity schema] &&
80
+ expected["schema"] == "labd-command-launcher/v1" && expected["identity"].is_a?(Hash) &&
81
+ expected["identity"]["uid"] == 0 && peer["uid"] == 0
82
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "installed command launcher differs"
83
+ end
84
+ target = expected.fetch("identity")
85
+ @peer.live!(target, deadline: deadline)
86
+ cursor = peer
87
+ 64.times do
88
+ return target if cursor.slice("pid", "uid", "host", "started_at") == target.slice("pid", "uid", "host", "started_at")
89
+ break unless RUBY_PLATFORM.include?("linux") && cursor["uid"] == 0
90
+ stat = File.read("/proc/#{cursor.fetch('pid')}/stat")
91
+ parent = Integer(stat[(stat.rindex(")") + 2)..].split.fetch(1), 10)
92
+ break if parent <= 1
93
+ @peer.live!(cursor, deadline: deadline)
94
+ identity = Ace::Runtime::Molecules::ProcessIdentity.new(executor:
95
+ Ace::Herdr::Molecules::BoundedObservation.new(deadline: deadline)).capture(parent)
96
+ break unless identity
97
+ cursor = identity
98
+ end
99
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "command helper is outside installed launcher ancestry"
100
+ rescue JSON::ParserError, SystemCallError, IndexError, ArgumentError
101
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "installed command launcher is unavailable"
102
+ end
103
+
104
+ def command_claim!(definition:, caller:, launcher:, deadline:)
105
+ command_launcher!(peer: launcher, deadline: deadline)
106
+ unless caller == definition.fetch("requester") && command_request!(selection: definition.fetch("selection"), peer: caller, deadline: deadline) == definition
107
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "command requester or policy changed"
108
+ end
109
+ true
110
+ end
111
+
112
+ def command_child!(definition:, child:, launcher:, deadline:)
113
+ command_launcher!(peer: launcher, deadline: deadline)
114
+ unless command_request!(selection: definition.fetch("selection"), peer: definition.fetch("requester"), deadline: deadline) == definition
115
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "command admission changed before exec"
116
+ end
117
+ # The authenticated root daemon witnesses cwd/identity after dropping
118
+ # the gated child. Owner independently captures its kernel generation.
119
+ keys = %w[pid uid gid groups started_at host cwd process_group no_new_privs capabilities]
120
+ actual = Ace::Runtime::Molecules::ProcessIdentity.new(executor:
121
+ Ace::Herdr::Molecules::BoundedObservation.new(deadline: deadline)).capture(child["pid"])
122
+ executor = definition.fetch("executor")
123
+ unless child.is_a?(Hash) && child.keys.sort == keys.sort && actual &&
124
+ actual == child.slice("pid", "uid", "started_at", "host") &&
125
+ child["no_new_privs"] == 1 && child["capabilities"].is_a?(Hash) &&
126
+ child["capabilities"].keys.sort == %w[CapAmb CapEff CapInh CapPrm] &&
127
+ child["capabilities"].values.all? { |value| value.is_a?(String) && value.match?(/\A0{16}\z/) } &&
128
+ child.values_at("uid", "gid", "groups", "cwd", "process_group") ==
129
+ [executor.fetch("uid"), executor.fetch("gid"), executor.fetch("groups"), executor.fetch("cwd"), child.fetch("pid")]
130
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "gated command child differs"
131
+ end
132
+ {"kernel_identity" => actual, "root_witness" => child, "witnessed_by" => launcher}
133
+ end
134
+
135
+ private
136
+
137
+ def policy = @policy_loader.call
138
+
139
+ def verify_context!(definition, deadline)
140
+ executor = definition.fetch("executor")
141
+ cwd = executor.fetch("cwd")
142
+ wire = Ace::Runtime::Molecules::ProtectedSocket
143
+ wire.root_path!(cwd, directory: true)
144
+ executor.fetch("path").each { |entry| GrantResolver.trusted_directory!(entry) }
145
+ home = executor.fetch("home")
146
+ GrantResolver.trusted_directory!(home) if File.exist?(home) || File.symlink?(home)
147
+ count = 0
148
+ Find.find(cwd) do |path|
149
+ raise InvalidConfigurationError, "command source exceeds verification bound" if (count += 1) > 20_000 || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
150
+ stat = File.lstat(path)
151
+ unless stat.uid.zero? && (stat.mode & 0o022).zero? && !stat.symlink? && (stat.file? || stat.directory?)
152
+ raise InvalidConfigurationError, "command source must be an immutable installed view"
153
+ end
154
+ end
155
+ executable = File.expand_path(definition.fetch("argv").first, cwd)
156
+ raise InvalidConfigurationError, "command executable exceeds verification bound" if File.size(executable) > 64 * 1024 * 1024
157
+ bytes = GrantResolver.read_verified(executable)
158
+ unless bytes && File.executable?(executable) && Digest::SHA256.hexdigest(bytes) == definition.fetch("executable_sha256")
159
+ raise InvalidConfigurationError, "command executable differs"
160
+ end
161
+ source = definition.fetch("source")
162
+ if source.fetch("head")
163
+ result = Ace::Herdr::Molecules::BoundedProcess.call(["/usr/bin/git", "-c", "safe.directory=#{cwd}",
164
+ "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "rev-parse", "HEAD", "HEAD^{tree}"],
165
+ environment: Ace::Assign::Molecules::InstalledJournalRead::ENVIRONMENT, chdir: cwd,
166
+ stdin_data: "", timeout_s: deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), output_limit: 1024, cleanup_group: true)
167
+ unless result.status.success? && result.stdout.lines.map(&:strip) == source.values_at("head", "tree")
168
+ raise InvalidConfigurationError, "command accepted source differs"
169
+ end
170
+ clean = Ace::Herdr::Molecules::BoundedProcess.call(["/usr/bin/git", "-c", "safe.directory=#{cwd}",
171
+ "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "status", "--porcelain=v1", "-z", "--untracked-files=all"],
172
+ environment: Ace::Assign::Molecules::InstalledJournalRead::ENVIRONMENT, chdir: cwd,
173
+ stdin_data: "", timeout_s: deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), output_limit: 65_536, cleanup_group: true)
174
+ unless clean.status.success? && clean.stdout.empty? && !clean.oversized
175
+ raise InvalidConfigurationError, "command accepted source is dirty"
176
+ end
177
+ end
178
+ true
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+ require "digest"
6
+ require "ace/assign/atoms/evidence_digest"
7
+ require_relative "grant_resolver"
8
+
9
+ module Ace
10
+ module Lab
11
+ module Molecules
12
+ # One deployment-owned policy, independent of repository preferences.
13
+ # The selected grant covers the WHOLE service combination and exact argv.
14
+ class CommandPolicy
15
+ PATH = "/etc/lab/ace-lab/project-commands.yml"
16
+ ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}\z/
17
+ ENV_KEY = /\A[A-Z][A-Z0-9_]{0,63}\z/
18
+ RESERVED = %w[HOME USER LOGNAME SHELL PATH RUBYOPT RUBYLIB BUNDLE_GEMFILE GEM_HOME GEM_PATH BASH_ENV ENV PYTHONPATH PYTHONHOME PERL5LIB LD_PRELOAD LD_LIBRARY_PATH].freeze
19
+ attr_reader :project, :launcher
20
+
21
+ def self.load(project:)
22
+ bytes = GrantResolver.read_verified(PATH)
23
+ raise InvalidConfigurationError, "project commands are not installed" unless bytes && bytes.bytesize <= 65_536
24
+ new(YAML.safe_load(bytes, aliases: false), project: project)
25
+ rescue Psych::Exception
26
+ raise InvalidConfigurationError, "invalid project command policy"
27
+ end
28
+
29
+ def initialize(document, project:)
30
+ strict!(document, %w[version projects])
31
+ invalid! unless document["version"] == 1 && document["projects"].is_a?(Hash) &&
32
+ document["projects"].keys.all? { |id| id.is_a?(String) && ID.match?(id) } && project.is_a?(String) && ID.match?(project)
33
+ @project = project
34
+ @document = JSON.parse(JSON.generate(document))
35
+ @entry = @document.fetch("projects").fetch(project) { invalid! }
36
+ strict!(@entry, %w[launcher default_executor default_services executors services commands grants])
37
+ @launcher = @entry.fetch("launcher")
38
+ strict!(@launcher, %w[socket_path identity_path])
39
+ invalid! unless @launcher.values.all? { |value| absolute?(value) }
40
+ %w[executors services commands].each do |key|
41
+ value = @entry.fetch(key)
42
+ invalid! unless value.is_a?(Hash) && value.size <= 64 && value.keys.all? { |id| id.is_a?(String) && ID.match?(id) }
43
+ end
44
+ @entry.fetch("executors").each_value do |value|
45
+ strict!(value, %w[uid gid groups home cwd path environment])
46
+ invalid! unless %w[uid gid].all? { |key| uid?(value[key]) } &&
47
+ value["groups"].is_a?(Array) && value["groups"].all? { |gid| uid?(gid) } &&
48
+ value["groups"].uniq == value["groups"] &&
49
+ %w[home cwd].all? { |key| absolute?(value[key]) } &&
50
+ value["path"].is_a?(Array) && !value["path"].empty? && value["path"].all? { |item| absolute?(item) }
51
+ environment!(value.fetch("environment"))
52
+ end
53
+ @entry.fetch("services").each_value do |value|
54
+ strict!(value, %w[environment secret_references])
55
+ environment!(value.fetch("environment"))
56
+ references = value.fetch("secret_references")
57
+ invalid! unless references.is_a?(Hash) && references.all? { |key, path| env_key?(key) && absolute?(path) }
58
+ end
59
+ @entry.fetch("commands").each_value do |value|
60
+ strict!(value, %w[argv executable_sha256 source purpose])
61
+ invalid! unless %w[command test].include?(value["purpose"])
62
+ argv!(value.fetch("argv"))
63
+ invalid! unless sha?(value.fetch("executable_sha256"))
64
+ source = value.fetch("source")
65
+ strict!(source, %w[head tree])
66
+ invalid! unless source.values.all? { |sha| sha.nil? || (sha.is_a?(String) && sha.match?(/\A[0-9a-f]{40}\z/)) } &&
67
+ source.values.all?(&:nil?) == source.values.any?(&:nil?)
68
+ invalid! if value["purpose"] == "test" && source.values.any?(&:nil?)
69
+ end
70
+ invalid! unless @entry.fetch("executors").key?(@entry["default_executor"])
71
+ services!(@entry.fetch("default_services"))
72
+ invalid! unless @entry["grants"].is_a?(Array) && @entry["grants"].size <= 256
73
+ @entry["grants"].each do |grant|
74
+ strict!(grant, %w[kind uid command executor services])
75
+ invalid! unless %w[operator agent].include?(grant["kind"]) && uid?(grant["uid"]) &&
76
+ @entry.fetch("commands").key?(grant["command"]) && @entry.fetch("executors").key?(grant["executor"])
77
+ services!(grant.fetch("services"))
78
+ end
79
+ @fingerprint = Ace::Assign::Atoms::EvidenceDigest.digest(@entry)
80
+ freeze_value(@document)
81
+ end
82
+
83
+ def reader!(uid)
84
+ deny! unless @entry.fetch("grants").any? { |grant| grant.fetch("uid") == uid }
85
+ true
86
+ end
87
+
88
+ def select(selection:, peer:)
89
+ strict!(selection, %w[argv executor services pane])
90
+ argv!(selection.fetch("argv"))
91
+ executor_id = selection["executor"] || @entry.fetch("default_executor")
92
+ executor = @entry.fetch("executors")[executor_id]
93
+ deny! unless executor
94
+ requested = selection["services"].nil? ? @entry.fetch("default_services") : selection["services"]
95
+ services!(requested)
96
+ selected = requested.uniq.sort
97
+ matches = @entry.fetch("commands").select { |_name, value| value.fetch("argv") == selection["argv"] }
98
+ deny! unless matches.size == 1
99
+ command_id, command = matches.first
100
+ kind = selection["pane"].nil? ? "operator" : "agent"
101
+ deny! unless @entry.fetch("grants").any? { |grant|
102
+ grant.values_at("kind", "uid", "command", "executor") == [kind, peer.fetch("uid"), command_id, executor_id] &&
103
+ grant.fetch("services").sort == selected }
104
+ environment = executor.fetch("environment").dup
105
+ references = {}
106
+ selected.each do |id|
107
+ service = @entry.fetch("services").fetch(id)
108
+ additions = service.fetch("environment")
109
+ secrets = service.fetch("secret_references")
110
+ deny! unless (environment.keys & (additions.keys + secrets.keys)).empty? &&
111
+ (references.keys & (additions.keys + secrets.keys)).empty? && (additions.keys & secrets.keys).empty?
112
+ environment.merge!(additions)
113
+ references.merge!(secrets)
114
+ end
115
+ definition = {"policy_sha256" => @fingerprint, "project_id" => @project, "command_id" => command_id,
116
+ "argv" => command.fetch("argv"), "executable_sha256" => command.fetch("executable_sha256"),
117
+ "source" => command.fetch("source"), "purpose" => command.fetch("purpose"), "executor_id" => executor_id,
118
+ "executor" => executor.merge("environment" => environment, "groups" => executor.fetch("groups").sort), "services" => selected,
119
+ "secret_references" => references, "requester" => peer, "requester_kind" => kind,
120
+ "selection" => selection}
121
+ invalid! if JSON.generate(definition).bytesize > 8192
122
+ definition
123
+ end
124
+
125
+ private
126
+
127
+ def strict!(value, keys)
128
+ invalid! unless value.is_a?(Hash) && value.keys.sort == keys.sort
129
+ end
130
+ def argv!(value)
131
+ invalid! unless value.is_a?(Array) && value.size.between?(1, 128) &&
132
+ value.all? { |item| item.is_a?(String) && item.bytesize <= 4096 && !item.include?("\0") } &&
133
+ !value.first.empty? && value.sum(&:bytesize) <= 8192
134
+ end
135
+ def services!(value)
136
+ invalid! unless value.is_a?(Array) && value.size <= 64 && value.all? { |id| @entry.fetch("services").key?(id) }
137
+ end
138
+ def environment!(value)
139
+ invalid! unless value.is_a?(Hash) && value.size <= 64 && value.all? { |key, item|
140
+ env_key?(key) && item.is_a?(String) && item.bytesize <= 4096 && !item.include?("\0") }
141
+ end
142
+ def env_key?(key) = key.is_a?(String) && ENV_KEY.match?(key) && !RESERVED.include?(key) && !key.start_with?("GIT_CONFIG_", "BUNDLE_", "LD_", "DYLD_")
143
+ def absolute?(value) = value.is_a?(String) && value.start_with?("/") && !value.include?("\0") && File.expand_path(value) == value
144
+ def uid?(value) = value.is_a?(Integer) && value.between?(1, 2**31 - 1)
145
+ def sha?(value) = value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
146
+ def invalid! = raise(InvalidConfigurationError, "invalid project command policy")
147
+ def deny! = raise(SecurityError, "project command selection is not authorized")
148
+ def freeze_value(value)
149
+ case value
150
+ when Hash then value.each { |key, child| key.freeze; freeze_value(child) }
151
+ when Array then value.each { |child| freeze_value(child) }
152
+ end
153
+ value.freeze
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -43,6 +43,21 @@ module Ace
43
43
  "invalid lab configuration: trusted authorization file could not be parsed"
44
44
  end
45
45
 
46
+ # Validate an installed directory through the same original-path
47
+ # traversal as files, including root-owned system symlink aliases.
48
+ def trusted_directory!(path)
49
+ candidate = verified_resolve(path)
50
+ raise Ace::Lab::InvalidConfigurationError, "trusted directory is unavailable" unless candidate
51
+ File.open(candidate, File::RDONLY | File::NOFOLLOW) do |handle|
52
+ unless handle.stat.directory? && secure_file_stat?(handle.stat)
53
+ raise Ace::Lab::InvalidConfigurationError, "trusted directory ownership or mode differs"
54
+ end
55
+ end
56
+ true
57
+ rescue SystemCallError
58
+ raise Ace::Lab::InvalidConfigurationError, "trusted directory is unavailable"
59
+ end
60
+
46
61
  # @param documents [Array<Hash>] {path:, document:, defaults:}
47
62
  # from Ace::Lab.cascade_documents
48
63
  # @param topology [Hash] normalized topology (project IDs)
@@ -43,13 +43,16 @@ module Ace
43
43
  # @param path [String, nil] trusted policy path to re-read per decision
44
44
  # @param loader [Proc, nil] trusted reader (default: GrantResolver);
45
45
  # the only override point, for non-privileged test documents
46
- def initialize(policy_document, path: nil, loader: nil)
46
+ def initialize(policy_document, path: nil, loader: nil, proposal_resolver: nil, caller_uid: nil)
47
47
  unless policy_document.is_a?(Hash) && policy_document["authorizations"].is_a?(Hash)
48
48
  raise Ace::Lab::InvalidConfigurationError,
49
49
  "trusted policy document must contain an authorizations mapping"
50
50
  end
51
51
  @authorizations = policy_document.fetch("authorizations")
52
+ @configuration_digest = ServicePolicy.configuration_digest(policy_document)
52
53
  @path = path
54
+ @proposal_resolver = proposal_resolver
55
+ @caller_uid = caller_uid
53
56
  @loader = loader || ->(trusted_path) { GrantResolver.trusted_document(trusted_path) }
54
57
  freeze
55
58
  end
@@ -65,6 +68,18 @@ module Ace
65
68
  unless binding.is_a?(Hash) && binding.fetch("operation", nil) == OPERATION
66
69
  return refuse("only the ordinary merge operation is resolvable")
67
70
  end
71
+ if reference.start_with?("proposal-")
72
+ return refuse("canonical proposal authority is unavailable") unless @proposal_resolver && @caller_uid.is_a?(Integer)
73
+ begin
74
+ decision = @proposal_resolver.call(reference, binding.slice(
75
+ "operation", "project_id", "assignment_id", "attempt_id", "input_digest", "candidate_head").merge(
76
+ "target" => {"resource" => binding.fetch("pr_url"), "artifact_digest" => nil},
77
+ "caller_uid" => @caller_uid))
78
+ return Decision.new(authorized?: true, decision: decision, code: nil, message: nil)
79
+ rescue Ace::Assign::Error
80
+ return refuse("canonical proposal does not authorize this exact ordinary merge")
81
+ end
82
+ end
68
83
  decision = authorizations[reference]
69
84
  return refuse("authorization reference is unresolved") unless decision.is_a?(Hash)
70
85
  unless DECISION_KEYS.all? { |key| decision.key?(key) }
@@ -99,6 +114,10 @@ module Ace
99
114
  "invalid lab configuration: trusted authorization refresh at #{@path} " \
100
115
  "did not yield an authorizations mapping"
101
116
  end
117
+ unless ServicePolicy.configuration_digest(document) == @configuration_digest
118
+ raise Ace::Lab::InvalidConfigurationError,
119
+ "ordinary receiver configuration changed; restart with the installed configuration"
120
+ end
102
121
  document.fetch("authorizations")
103
122
  end
104
123