ace-herdr 0.3.2 → 0.5.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/herdr/config.yml +0 -4
  3. data/CHANGELOG.md +30 -0
  4. data/docs/usage.md +65 -32
  5. data/lib/ace/herdr/cli/commands/inbox.rb +81 -31
  6. data/lib/ace/herdr/cli/commands/native_source.rb +56 -0
  7. data/lib/ace/herdr/cli/commands/support.rb +2 -6
  8. data/lib/ace/herdr/cli.rb +3 -0
  9. data/lib/ace/herdr/errors.rb +23 -0
  10. data/lib/ace/herdr/molecules/bounded_process.rb +266 -51
  11. data/lib/ace/herdr/molecules/delivery_record_store.rb +81 -5
  12. data/lib/ace/herdr/molecules/guarded_native_origin.rb +59 -0
  13. data/lib/ace/herdr/molecules/herdr_executor.rb +6 -3
  14. data/lib/ace/herdr/molecules/inbox_cli_input.rb +36 -0
  15. data/lib/ace/herdr/molecules/inbox_context_client.rb +88 -0
  16. data/lib/ace/herdr/molecules/inbox_context_completion_client.rb +119 -0
  17. data/lib/ace/herdr/molecules/inbox_context_key.rb +63 -0
  18. data/lib/ace/herdr/molecules/inbox_context_lifetime.rb +122 -0
  19. data/lib/ace/herdr/molecules/inbox_context_native_process.rb +92 -0
  20. data/lib/ace/herdr/molecules/inbox_context_service_configuration.rb +183 -0
  21. data/lib/ace/herdr/molecules/inbox_context_store.rb +159 -0
  22. data/lib/ace/herdr/molecules/inbox_context_wire.rb +76 -0
  23. data/lib/ace/herdr/molecules/inbox_direct_effect_binding.rb +92 -0
  24. data/lib/ace/herdr/molecules/inbox_direct_result.rb +65 -0
  25. data/lib/ace/herdr/molecules/native_queue_executor.rb +10 -21
  26. data/lib/ace/herdr/molecules/native_source.rb +189 -0
  27. data/lib/ace/herdr/molecules/protected_inbox_selection.rb +126 -0
  28. data/lib/ace/herdr/molecules/protected_native_control.rb +259 -0
  29. data/lib/ace/herdr/native_source/HERDR-LICENSE +201 -0
  30. data/lib/ace/herdr/native_source/guarded-prompt.patch +3493 -0
  31. data/lib/ace/herdr/native_source/selection.json +24 -0
  32. data/lib/ace/herdr/organisms/deliverer.rb +2 -3
  33. data/lib/ace/herdr/organisms/inbox.rb +384 -137
  34. data/lib/ace/herdr/organisms/inbox_context_direct_effects.rb +285 -0
  35. data/lib/ace/herdr/organisms/inbox_context_effects.rb +30 -0
  36. data/lib/ace/herdr/organisms/inbox_context_listener.rb +151 -0
  37. data/lib/ace/herdr/organisms/inbox_context_owner.rb +550 -0
  38. data/lib/ace/herdr/organisms/inbox_context_server.rb +70 -0
  39. data/lib/ace/herdr/organisms/inbox_context_service.rb +107 -0
  40. data/lib/ace/herdr/organisms/inbox_guarded_wake.rb +117 -0
  41. data/lib/ace/herdr/organisms/native_source_builder.rb +183 -0
  42. data/lib/ace/herdr/organisms/runtime_adapter.rb +30 -1
  43. data/lib/ace/herdr/version.rb +1 -1
  44. data/lib/ace/herdr.rb +5 -28
  45. metadata +69 -8
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/runtime/molecules/protected_linux"
4
+ require_relative "inbox_context_wire"
5
+ require_relative "inbox_direct_result"
6
+
7
+ module Ace
8
+ module Herdr
9
+ module Molecules
10
+ # Constructor inputs come only from the verified installed context map.
11
+ class InboxContextClient
12
+ def self.selected(context_id:, socket_path:, owner_credentials:, **options)
13
+ new(context_id: context_id, socket_path: socket_path, owner_identity: nil,
14
+ owner_credentials: owner_credentials, **options)
15
+ end
16
+
17
+ def initialize(context_id:, socket_path:, owner_identity:, owner_credentials: nil, kernel: Ace::Runtime::Molecules::ProtectedLinux.new,
18
+ wire: Ace::Runtime::Molecules::ProtectedSocket)
19
+ @context_id, @socket_path, @identity, @kernel, @wire = context_id, socket_path, owner_identity, kernel, wire
20
+ @credentials = owner_credentials || owner_identity&.slice("uid", "gid", "groups")
21
+ unless @credentials.is_a?(Hash) && @credentials.keys.sort == %w[gid groups uid] &&
22
+ @credentials.values_at("uid", "gid").all? { |value| value.is_a?(Integer) && value.positive? } &&
23
+ @credentials["groups"].is_a?(Array) && @credentials["groups"].size <= 64 &&
24
+ @credentials["groups"].all? { |value| value.is_a?(Integer) && value.positive? } && @credentials["groups"] == @credentials["groups"].sort.uniq
25
+ raise ValidationError, "context fixed owner credentials differ"
26
+ end
27
+ @selection_mutex = Mutex.new
28
+ end
29
+
30
+ def request(operation, params, payload: nil)
31
+ if operation == "enqueue_context"
32
+ unless payload.is_a?(String) && payload.bytesize.between?(1, 65_536) &&
33
+ params["payload_bytes"] == payload.bytesize && params["payload_sha256"] == Digest::SHA256.hexdigest(payload)
34
+ raise ValidationError, "context payload differs"
35
+ end
36
+ elsif payload
37
+ raise ValidationError, "context operation does not accept payload"
38
+ end
39
+ @wire.root_path!(File.dirname(@socket_path), directory: true, owner: @credentials.fetch("uid"))
40
+ before = @wire.socket_identity(@socket_path)
41
+ raise ValidationError, "context socket owner differs" unless before[2] == @credentials.fetch("uid")
42
+ @kernel.live!(@identity) if @identity
43
+ deadline = InboxContextWire.deadline
44
+ @wire.connect(@socket_path, deadline: deadline) do |socket|
45
+ observed = @kernel.peer(socket)
46
+ unless observed.values_at("uid", "gid", "groups") == @credentials.values_at("uid", "gid", "groups")
47
+ raise ValidationError, "context fixed owner principal differs"
48
+ end
49
+ @selection_mutex.synchronize { @identity ||= JSON.parse(JSON.generate(observed)).freeze }
50
+ @kernel.live!(@identity)
51
+ unless @kernel.same?(@identity, observed) && @wire.socket_identity(@socket_path) == before
52
+ raise ValidationError, "context owner incarnation differs"
53
+ end
54
+ InboxContextWire.write(socket, {"version" => 1, "context_id" => @context_id,
55
+ "operation" => operation, "params" => params}, deadline: deadline)
56
+ if payload
57
+ # Same bounded writer; a one-part raw ingress has the larger native payload bound.
58
+ offset = 0
59
+ while offset < payload.bytesize
60
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
+ raise ValidationError, "context payload deadline expired" unless remaining.positive? && IO.select(nil, [socket], nil, remaining)
62
+ sent = socket.write_nonblock(payload.byteslice(offset..), exception: false)
63
+ next if sent == :wait_writable
64
+ offset += sent
65
+ end
66
+ end
67
+ socket.shutdown(Socket::SHUT_WR)
68
+ result = InboxContextWire.read(socket, deadline: deadline)
69
+ unless @kernel.same?(@identity, @kernel.peer(socket)) && @wire.socket_identity(@socket_path) == before
70
+ raise ValidationError, "context owner incarnation changed"
71
+ end
72
+ unless result.is_a?(Hash) && result["version"].is_a?(Integer) && result["version"] == 1 &&
73
+ result["context_id"] == @context_id && result.keys.sort == %w[context_id result version] && result["result"].is_a?(Hash)
74
+ raise ValidationError, "context operation is blocked or unverifiable"
75
+ end
76
+ data = result.fetch("result")
77
+ if %w[enqueue_context deliver_context status_context].include?(operation)
78
+ InboxDirectResult.verify!(data, operation: operation, event_id: params.fetch("event_id"), attempt_id: params.fetch("attempt_id"), original: params.fetch("original"))
79
+ end
80
+ data
81
+ end
82
+ rescue Ace::Runtime::RuntimeUnavailableError, SystemCallError
83
+ raise ValidationError, "fixed context endpoint is unavailable"
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/runtime/molecules/protected_linux"
4
+ require "ace/runtime/molecules/protected_socket"
5
+ require_relative "inbox_direct_effect_binding"
6
+ require_relative "guarded_native_origin"
7
+
8
+ module Ace
9
+ module Herdr
10
+ module Molecules
11
+ # Fixed installed authority selection; there is no caller-selected verifier
12
+ # callback, generic finished flag, current Inbox lookup or local fallback.
13
+ class InboxContextCompletionClient
14
+ LIMIT = 16_384
15
+
16
+ def initialize(authority:, project_id:, mapping_id:, kernel: Ace::Runtime::Molecules::ProtectedLinux.new,
17
+ wire: Ace::Runtime::Molecules::ProtectedSocket)
18
+ @authority, @project_id, @mapping_id, @kernel, @wire = authority, project_id, mapping_id, kernel, wire
19
+ end
20
+
21
+
22
+
23
+ # Identity evidence only: callers must independently own admission and
24
+ # exact native context/thread selection before any native effect.
25
+ def original!(assignment_id:, attempt_id:, event_id:, inbox_context_id:, purpose:, payload_sha256:)
26
+ params = {"mapping_id" => @mapping_id, "assignment_id" => assignment_id, "attempt_id" => attempt_id,
27
+ "event_id" => event_id, "inbox_context_id" => inbox_context_id, "purpose" => purpose,
28
+ "payload_sha256" => payload_sha256}
29
+ unless %w[assignment_id attempt_id event_id inbox_context_id mapping_id].all? { |key|
30
+ params[key].is_a?(String) && params[key].match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/) } &&
31
+ %w[enqueue deliver].include?(purpose) && %w[payload_sha256].all? { |key| (params[key].is_a?(String) && InboxDirectEffectBinding::SHA.match?(params[key])) }
32
+ raise ValidationError, "original context selection differs"
33
+ end
34
+ query!(operation: "inbox_context_original", params: params) { |data| verify_original!(data, params) }
35
+ end
36
+
37
+ private
38
+
39
+ def query!(operation:, params:)
40
+ path = @authority.fetch("socket_path")
41
+ @wire.root_path!(File.dirname(path), directory: true, owner: @authority.fetch("uid"))
42
+ endpoint = @wire.socket_identity(path)
43
+ raise ValidationError, "context completion authority endpoint differs" unless endpoint.last == @authority.fetch("uid")
44
+ deadline = @wire.deadline(30)
45
+ @wire.connect(path, deadline: deadline) do |socket|
46
+ original = @kernel.peer(socket)
47
+ unless original.values_at("uid", "gid", "groups") == @authority.values_at("uid", "gid", "groups") && @wire.socket_identity(path) == endpoint
48
+ raise ValidationError, "context completion authority principal differs"
49
+ end
50
+ @kernel.live!(original)
51
+ @wire.write(socket, {"version" => 1, "project_id" => @project_id, "operation" => operation,
52
+ "mutation_id" => nil, "params" => params}, deadline: deadline, limit: LIMIT)
53
+ socket.shutdown(Socket::SHUT_WR)
54
+ reply = @wire.read(socket, deadline: deadline, limit: LIMIT)
55
+ unless @kernel.same?(original, @kernel.peer(socket)) && @wire.socket_identity(path) == endpoint
56
+ raise ValidationError, "context completion authority incarnation changed"
57
+ end
58
+ @kernel.live!(original)
59
+ unless reply.is_a?(Hash) && reply.keys.sort == %w[data status transport] && reply["status"] == "ok" &&
60
+ reply["transport"] == {"replayed" => false}
61
+ raise ValidationError, "context completion canonical query refused"
62
+ end
63
+ yield reply.fetch("data")
64
+ end
65
+ rescue KeyError, TypeError, ArgumentError, Ace::Runtime::RuntimeUnavailableError, SystemCallError, IOError
66
+ raise ValidationError, "context canonical query is unavailable"
67
+ end
68
+
69
+ def verify_original!(data, params)
70
+ fields = %w[assignment_id attempt_id commit event_id guarded_origin inbox_context_id mapping_id native_binding native_channel original_binding_digest process_binding project_id purpose registered registration schema]
71
+ registration = params.slice("event_id", "attempt_id", "payload_sha256")
72
+ unless data.is_a?(Hash) && data.keys.sort == fields && data["schema"] == "ace.assign.inbox-context-original/v1" &&
73
+ data.slice("assignment_id", "attempt_id", "event_id", "inbox_context_id", "mapping_id", "purpose") ==
74
+ params.slice("assignment_id", "attempt_id", "event_id", "inbox_context_id", "mapping_id", "purpose") &&
75
+ data["project_id"] == @project_id && data["registration"] == registration &&
76
+ [true, false].include?(data["registered"]) && (params.fetch("purpose") != "deliver" || data["registered"]) &&
77
+ data["commit"].is_a?(String) && data["commit"].match?(/\A[0-9a-f]{40}\z/) &&
78
+ (data["original_binding_digest"].is_a?(String) && InboxDirectEffectBinding::SHA.match?(data["original_binding_digest"])) &&
79
+ data["process_binding"].is_a?(Hash) && data["native_binding"].is_a?(Hash)
80
+ raise ValidationError, "original context projection differs"
81
+ end
82
+ process = data.fetch("process_binding")
83
+ native = data.fetch("native_binding")
84
+ channel = data.fetch("native_channel")
85
+ path = channel["socket_path"] if channel.is_a?(Hash)
86
+ unless channel.is_a?(Hash) && channel.keys.sort == %w[socket_path version] && channel["version"] == "0.9.3" &&
87
+ path.is_a?(String) && path.valid_encoding? && path.bytesize.between?(1, 107) && path.start_with?("/") &&
88
+ !path.include?("\0") && File.expand_path(path) == path
89
+ raise ValidationError, "original context native channel differs"
90
+ end
91
+ origin = process["native_origin"]
92
+ unless process.keys.sort == %w[native_origin pane process_identity runtime session shell_identity terminal_id] &&
93
+ native.keys.sort == %w[scope_binding_event_id scope_generation server_identity service_invocation_id socket_identity workspace_id] &&
94
+ origin.is_a?(Hash) && origin.keys.sort == %w[command cwd pane server_identity socket_identity tab workspace] &&
95
+ process["runtime"] == "herdr" && process["shell_identity"] == process["process_identity"] &&
96
+ process["session"] == native["workspace_id"] && origin["workspace"] == process["session"] &&
97
+ origin["pane"] == process["pane"] && origin["server_identity"] == native["server_identity"] &&
98
+ origin["socket_identity"] == native["socket_identity"] && native["server_identity"].is_a?(Hash) &&
99
+ process.dig("process_identity", "parent_pid") == native.dig("server_identity", "pid")
100
+ raise ValidationError, "original context native association differs"
101
+ end
102
+ GuardedNativeOrigin.verify!(data.fetch("guarded_origin"), terminal_id: process.fetch("terminal_id"), child: process.fetch("process_identity"))
103
+ immutable(data)
104
+ end
105
+
106
+
107
+
108
+ def immutable(value)
109
+ case value
110
+ when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
111
+ when Array then value.map { |item| immutable(item) }.freeze
112
+ when String then value.dup.freeze
113
+ else value.freeze
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "digest"
5
+ require "ace/runtime/molecules/protected_artifact_set"
6
+ require_relative "inbox_context_store"
7
+
8
+ module Ace
9
+ module Herdr
10
+ module Molecules
11
+ # Both fixed installer artifacts are read from held protected descriptors.
12
+ class InboxContextKey
13
+ LIMIT = 16_384
14
+ def initialize(context_id:, public_key_path:, config_path:, artifacts: Ace::Runtime::Molecules::ProtectedArtifactSet.new)
15
+ @context_id, @key_path, @config_path, @artifacts = context_id, public_key_path, config_path, artifacts
16
+ end
17
+
18
+ def snapshot = selected.fetch(:snapshot)
19
+
20
+ # The fresh verifier key and its metadata come from the same held pair.
21
+ # Rotation never leaves an Inbox collaborator pinned to an obsolete key.
22
+ def selected
23
+ @artifacts.with do |reader|
24
+ key_bytes, key_ref = reader.read_path!(@key_path, limit: LIMIT)
25
+ config_bytes, config_ref = reader.read_path!(@config_path, limit: LIMIT)
26
+ config = InboxContextStore.decode(config_bytes, limit: LIMIT)
27
+ unless config.is_a?(Hash) && config.keys.sort == %w[context_id key_generation public_key_sha256 schema] &&
28
+ config["schema"] == "ace.herdr.inbox-key/v1" && config["context_id"] == @context_id &&
29
+ config["key_generation"].is_a?(Integer) && config["key_generation"].between?(1, (1 << 63) - 1) &&
30
+ config["public_key_sha256"] == key_ref.fetch("sha256")
31
+ raise ValidationError, "installed context key configuration differs"
32
+ end
33
+ key = public_key!(key_bytes)
34
+ reader.verify_unchanged!
35
+ snapshot = {"key_generation" => config.fetch("key_generation"),
36
+ "fingerprint" => Digest::SHA256.hexdigest(key.public_to_der),
37
+ "public_key_sha256" => key_ref.fetch("sha256"), "config_sha256" => config_ref.fetch("sha256")}
38
+ {snapshot: snapshot.freeze, key: key.freeze}.freeze
39
+ end
40
+ rescue Ace::Runtime::RuntimeUnavailableError, OpenSSL::PKey::PKeyError, KeyError
41
+ raise ValidationError, "installed context key evidence is unavailable"
42
+ end
43
+
44
+ def self.public_key!(bytes)
45
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, LIMIT)
46
+ raise ValidationError, "context public key exceeds bounds"
47
+ end
48
+ key = OpenSSL::PKey.read(bytes)
49
+ unless key.is_a?(OpenSSL::PKey::RSA) && !key.private? && key.n.num_bits >= 2048
50
+ raise ValidationError, "context key must be public RSA"
51
+ end
52
+ key
53
+ rescue OpenSSL::PKey::PKeyError
54
+ raise ValidationError, "context public key is invalid"
55
+ end
56
+
57
+ private
58
+
59
+ def public_key!(bytes) = self.class.public_key!(bytes)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/runtime/molecules/execution_unit_installation"
4
+ require "ace/runtime/molecules/cgroup_observation"
5
+ require "ace/runtime/molecules/protected_linux"
6
+ require_relative "inbox_context_service_configuration"
7
+
8
+ module Ace
9
+ module Herdr
10
+ module Molecules
11
+ # An epoch is an observed service incarnation, not a caller grant. The
12
+ # full effective unit is verified before and after the pinned observation.
13
+ class InboxContextLifetime
14
+ EPOCH_FIELDS = %w[boot_id cgroup_identity installation_sha256 process_identity schema service_invocation_id service_unit].freeze
15
+ UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
16
+ class KernelFiles
17
+ def boot_id
18
+ bytes = File.read("/proc/sys/kernel/random/boot_id", 128).strip
19
+ unless bytes.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/)
20
+ raise ValidationError, "context boot identity is unavailable"
21
+ end
22
+ bytes
23
+ rescue SystemCallError, IOError
24
+ raise ValidationError, "context boot identity is unavailable"
25
+ end
26
+ end
27
+
28
+ attr_reader :epoch
29
+
30
+ # Persisted shape validation alone never establishes stopped writers.
31
+ def self.validate_epoch!(epoch)
32
+ unless epoch.is_a?(Hash) && epoch.keys.sort == EPOCH_FIELDS && epoch["schema"] == "ace.herdr.inbox-owner-epoch/v1" &&
33
+ epoch["installation_sha256"].is_a?(String) && epoch["installation_sha256"].match?(/\A[0-9a-f]{64}\z/) &&
34
+ epoch["boot_id"].is_a?(String) && UUID.match?(epoch["boot_id"]) &&
35
+ epoch["service_invocation_id"].is_a?(String) && epoch["service_invocation_id"].match?(/\A[0-9a-f]{32}\z/) &&
36
+ epoch["service_unit"].is_a?(String) && epoch["service_unit"].match?(/\A[A-Za-z0-9_.@-]+\.service\z/)
37
+ raise ValidationError, "context epoch fields differ"
38
+ end
39
+ process = epoch.fetch("process_identity")
40
+ unless process.is_a?(Hash) && process.keys.sort == %w[gid groups host parent_pid pid started_at uid] &&
41
+ process.values_at("pid", "parent_pid").all? { |value| value.is_a?(Integer) && value.between?(1, (1 << 31) - 1) } &&
42
+ process.values_at("uid", "gid").all? { |value| value.is_a?(Integer) && value.between?(1, InboxContextServiceConfiguration::MAX_ID) } &&
43
+ process["groups"].is_a?(Array) && process["groups"].size <= 64 && process["groups"] == process["groups"].sort.uniq &&
44
+ process["groups"].all? { |value| value.is_a?(Integer) && value.between?(0, InboxContextServiceConfiguration::MAX_ID) } &&
45
+ process["host"].is_a?(String) && process["host"].bytesize.between?(1, 255) &&
46
+ process["started_at"].is_a?(String) && process["started_at"].match?(/\Alinux:#{Regexp.escape(epoch.fetch('boot_id'))}:[1-9][0-9]*\z/)
47
+ raise ValidationError, "context epoch process differs"
48
+ end
49
+ cgroup = epoch.fetch("cgroup_identity")
50
+ root = Ace::Runtime::Molecules::CgroupObservation::ROOT
51
+ unless cgroup.is_a?(Hash) && cgroup.keys.sort == %w[device filesystem_type inode mount_id path] &&
52
+ cgroup["filesystem_type"] == "cgroup2" && cgroup["path"].is_a?(String) &&
53
+ cgroup["path"].bytesize <= 4096 && !cgroup["path"].match?(/[\s\0]/) && cgroup["path"].start_with?(root + "/") &&
54
+ File.expand_path(cgroup["path"]) == cgroup["path"] &&
55
+ cgroup.values_at("device", "inode", "mount_id").all? { |value| value.is_a?(Integer) && value.between?(0, (1 << 64) - 1) } &&
56
+ JSON.generate(epoch).bytesize <= 4096
57
+ raise ValidationError, "context epoch scope differs"
58
+ end
59
+ true
60
+ rescue KeyError, TypeError, ArgumentError
61
+ raise ValidationError, "context epoch is malformed"
62
+ end
63
+
64
+ def initialize(installation:, manager:, configuration:, kernel: Ace::Runtime::Molecules::ProtectedLinux.new,
65
+ cgroups: Ace::Runtime::Molecules::CgroupObservation.new, files: KernelFiles.new)
66
+ unless installation.is_a?(Ace::Runtime::Molecules::ExecutionUnitInstallation) &&
67
+ configuration.is_a?(InboxContextServiceConfiguration)
68
+ raise ValidationError, "context lifetime selection is not held"
69
+ end
70
+ @installation, @manager, @configuration, @kernel, @cgroups, @files = installation, manager, configuration, kernel, cgroups, files
71
+ unless installation.inbox_context_configuration_reference == configuration.reference
72
+ raise ValidationError, "context lifetime configuration association differs"
73
+ end
74
+ end
75
+
76
+ def capture_epoch!
77
+ raise ValidationError, "context epoch is already observed" if @epoch
78
+ scope = @installation.inbox_context_scope
79
+ @installation.verify!(manager: @manager)
80
+ boot = @files.boot_id
81
+ before = @manager.inspect_activation.fetch("service")
82
+ peer = @kernel.capture(Process.pid)
83
+ unless peer.values_at("uid", "gid", "groups") == @configuration.data.fetch("owner_credentials").values_at("uid", "gid", "groups") &&
84
+ before.fetch("Id") == scope.fetch("service_unit") && before.fetch("MainPID") == Process.pid &&
85
+ before.fetch("ControlPID").zero? && before.fetch("Job") == [0, "/"] &&
86
+ before.fetch("ActiveState") == "active" && before.fetch("SubState") == "running" &&
87
+ before.fetch("InvocationID").is_a?(String) && before.fetch("InvocationID").match?(/\A[0-9a-f]{32}\z/)
88
+ raise ValidationError, "context service incarnation differs"
89
+ end
90
+ path = Ace::Runtime::Molecules::CgroupObservation::ROOT + before.fetch("ControlGroup")
91
+ pinned = @cgroups.pin(path)
92
+ @cgroups.member!(peer, pinned: pinned, kernel: @kernel)
93
+ unless @cgroups.observe(pinned).fetch("populated") == 1 && @kernel.same?(peer, @kernel.capture(Process.pid)) &&
94
+ @manager.inspect_activation.fetch("service") == before && @files.boot_id == boot
95
+ raise ValidationError, "context service observation changed"
96
+ end
97
+ @installation.verify!(manager: @manager)
98
+ observed = {"schema" => "ace.herdr.inbox-owner-epoch/v1", "installation_sha256" => scope.fetch("unit_manifest_sha256"),
99
+ "boot_id" => boot, "service_unit" => scope.fetch("service_unit"), "service_invocation_id" => before.fetch("InvocationID"),
100
+ "cgroup_identity" => pinned.fetch(:identity), "process_identity" => peer}
101
+ self.class.validate_epoch!(observed)
102
+ @epoch = immutable(observed)
103
+ rescue KeyError, TypeError, Ace::Runtime::RuntimeUnavailableError, SystemCallError, IOError
104
+ raise ValidationError, "context service epoch is unavailable"
105
+ ensure
106
+ pinned&.fetch(:handle)&.close
107
+ end
108
+
109
+ private
110
+
111
+ def immutable(value)
112
+ case value
113
+ when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
114
+ when Array then value.map { |item| immutable(item) }.freeze
115
+ when String then value.dup.freeze
116
+ else value.freeze
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/runtime/molecules/protected_artifact_set"
4
+ require_relative "inbox_context_service_configuration"
5
+ require_relative "bounded_process"
6
+
7
+ module Ace
8
+ module Herdr
9
+ module Molecules
10
+ # Exact accepted executable inode, fixed environment and startup closure.
11
+ # This is process construction, not original-target authorization.
12
+ class InboxContextNativeProcess
13
+ def initialize(configuration:, artifacts_factory: nil, process: BoundedProcess)
14
+ unless configuration.is_a?(InboxContextServiceConfiguration)
15
+ raise ValidationError, "context native selection is not held"
16
+ end
17
+ @selection = configuration.data.fetch("native_clients")
18
+ @credentials = configuration.data.fetch("owner_credentials")
19
+ @artifacts_factory = artifacts_factory || -> { Ace::Runtime::Molecules::ProtectedArtifactSet.new(
20
+ file_limit: 268_435_456, total_limit: 268_435_456, count_limit: 512) }
21
+ @process = process
22
+ end
23
+
24
+ def verify!
25
+ @artifacts_factory.call.with do |reader|
26
+ references.each { |ref| reader.read!(ref) }
27
+ @selection.values_at("codex", "pi", "herdr").each do |ref|
28
+ reader.with_readonly_handle!(ref) { |handle| verify_executable!(handle) }
29
+ end
30
+ resource_snapshot!
31
+ reader.verify_unchanged!
32
+ end
33
+ true
34
+ rescue Ace::Runtime::RuntimeUnavailableError, SystemCallError, IOError
35
+ raise ValidationError, "context native release is unavailable"
36
+ end
37
+
38
+ def call(argv, stdin_data:, timeout_s:, output_limit: 65_536)
39
+ selected = @selection.values_at("codex", "pi", "herdr").find { |ref| ref.fetch("path") == argv.first }
40
+ raise ValidationError, "context native executable is not selected" unless selected
41
+ @artifacts_factory.call.with do |reader|
42
+ references.each { |ref| reader.read!(ref) }
43
+ resources = resource_snapshot!
44
+ reader.with_readonly_handle!(selected) do |handle|
45
+ verify_executable!(handle)
46
+ result = @process.call(["/proc/self/fd/5", *argv.drop(1)], stdin_data: stdin_data, timeout_s: timeout_s,
47
+ output_limit: output_limit, environment: @selection.fetch("environment"),
48
+ chdir: @selection.fetch("cwd"), descriptor_mapping: {5 => handle})
49
+ raise ValidationError, "context native resource changed" unless resource_snapshot! == resources
50
+ result
51
+ end
52
+ end
53
+ rescue Ace::Runtime::RuntimeUnavailableError, SystemCallError, IOError
54
+ raise ValidationError, "context native executable evidence is unavailable"
55
+ end
56
+
57
+ private
58
+
59
+ def references = @selection.values_at("codex", "pi", "herdr") + @selection.fetch("dependencies")
60
+
61
+ def access_bits(stat)
62
+ if stat.uid == @credentials.fetch("uid")
63
+ (stat.mode >> 6) & 7
64
+ elsif (@credentials.fetch("groups") + [@credentials.fetch("gid")]).include?(stat.gid)
65
+ (stat.mode >> 3) & 7
66
+ else
67
+ stat.mode & 7
68
+ end
69
+ end
70
+
71
+ def verify_executable!(handle)
72
+ raise ValidationError, "context native executable mode differs" unless (access_bits(handle.stat) & 1) == 1
73
+ end
74
+
75
+ def resource_snapshot!
76
+ @selection.fetch("resources").map do |row|
77
+ stat = File.lstat(row.fetch("path"))
78
+ kind = row.fetch("kind") == "directory" ? stat.directory? : stat.socket?
79
+ unless kind && !stat.symlink? && stat.uid == row.fetch("uid") && stat.gid == row.fetch("gid") &&
80
+ (stat.mode & 0o7777) == row.fetch("mode")
81
+ raise ValidationError, "context native resource placement differs"
82
+ end
83
+ bits = access_bits(stat)
84
+ required = row.fetch("kind") == "socket" ? 2 : row.fetch("access") == "write" ? 3 : 5
85
+ raise ValidationError, "context native resource access differs" unless (bits & required) == required
86
+ [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode]
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end