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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/docs/usage.md +343 -0
- data/lib/ace/lab/cli/commands/assignment.rb +92 -0
- data/lib/ace/lab/cli/commands/command_execute.rb +28 -0
- data/lib/ace/lab/cli/commands/run.rb +65 -0
- data/lib/ace/lab/cli/commands/service.rb +12 -28
- data/lib/ace/lab/cli.rb +16 -1
- data/lib/ace/lab/molecules/command_owner_admission.rb +183 -0
- data/lib/ace/lab/molecules/command_policy.rb +158 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +15 -0
- data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +20 -1
- data/lib/ace/lab/molecules/ordinary_listener.rb +41 -17
- data/lib/ace/lab/molecules/ordinary_merge_read.rb +45 -0
- data/lib/ace/lab/molecules/ordinary_native_read.rb +106 -0
- data/lib/ace/lab/molecules/ordinary_owner_admission.rb +152 -0
- data/lib/ace/lab/molecules/ordinary_owner_config.rb +232 -0
- data/lib/ace/lab/molecules/ordinary_receiver_config.rb +12 -4
- data/lib/ace/lab/molecules/ordinary_socket.rb +6 -9
- data/lib/ace/lab/molecules/ordinary_worktree_read.rb +216 -0
- data/lib/ace/lab/molecules/service_executor.rb +9 -39
- data/lib/ace/lab/molecules/service_policy.rb +15 -4
- data/lib/ace/lab/organisms/authority_composition.rb +1 -1
- data/lib/ace/lab/organisms/command_executor.rb +220 -0
- data/lib/ace/lab/organisms/command_run_client.rb +131 -0
- data/lib/ace/lab/organisms/ordinary_assignment_client.rb +120 -0
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +153 -0
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +77 -61
- data/lib/ace/lab/organisms/ordinary_merge_request.rb +41 -7
- data/lib/ace/lab/version.rb +1 -1
- metadata +33 -6
- 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
|
|
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
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|