ace-herdr 0.4.0 → 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.
- checksums.yaml +4 -4
- data/.ace-defaults/herdr/config.yml +0 -4
- data/CHANGELOG.md +12 -0
- data/docs/usage.md +57 -35
- data/lib/ace/herdr/cli/commands/inbox.rb +83 -21
- data/lib/ace/herdr/cli/commands/native_source.rb +56 -0
- data/lib/ace/herdr/cli.rb +3 -0
- data/lib/ace/herdr/errors.rb +23 -0
- data/lib/ace/herdr/molecules/bounded_process.rb +261 -52
- data/lib/ace/herdr/molecules/delivery_record_store.rb +81 -6
- data/lib/ace/herdr/molecules/guarded_native_origin.rb +59 -0
- data/lib/ace/herdr/molecules/herdr_executor.rb +6 -3
- data/lib/ace/herdr/molecules/inbox_cli_input.rb +36 -0
- data/lib/ace/herdr/molecules/inbox_context_client.rb +88 -0
- data/lib/ace/herdr/molecules/inbox_context_completion_client.rb +119 -0
- data/lib/ace/herdr/molecules/inbox_context_key.rb +63 -0
- data/lib/ace/herdr/molecules/inbox_context_lifetime.rb +122 -0
- data/lib/ace/herdr/molecules/inbox_context_native_process.rb +92 -0
- data/lib/ace/herdr/molecules/inbox_context_service_configuration.rb +183 -0
- data/lib/ace/herdr/molecules/inbox_context_store.rb +159 -0
- data/lib/ace/herdr/molecules/inbox_context_wire.rb +76 -0
- data/lib/ace/herdr/molecules/inbox_direct_effect_binding.rb +92 -0
- data/lib/ace/herdr/molecules/inbox_direct_result.rb +65 -0
- data/lib/ace/herdr/molecules/native_queue_executor.rb +10 -21
- data/lib/ace/herdr/molecules/native_source.rb +189 -0
- data/lib/ace/herdr/molecules/protected_inbox_selection.rb +126 -0
- data/lib/ace/herdr/molecules/protected_native_control.rb +129 -4
- data/lib/ace/herdr/native_source/HERDR-LICENSE +201 -0
- data/lib/ace/herdr/native_source/guarded-prompt.patch +3493 -0
- data/lib/ace/herdr/native_source/selection.json +24 -0
- data/lib/ace/herdr/organisms/inbox.rb +293 -203
- data/lib/ace/herdr/organisms/inbox_context_direct_effects.rb +285 -0
- data/lib/ace/herdr/organisms/inbox_context_effects.rb +30 -0
- data/lib/ace/herdr/organisms/inbox_context_listener.rb +151 -0
- data/lib/ace/herdr/organisms/inbox_context_owner.rb +550 -0
- data/lib/ace/herdr/organisms/inbox_context_server.rb +70 -0
- data/lib/ace/herdr/organisms/inbox_context_service.rb +107 -0
- data/lib/ace/herdr/organisms/inbox_guarded_wake.rb +117 -0
- data/lib/ace/herdr/organisms/native_source_builder.rb +183 -0
- data/lib/ace/herdr/version.rb +1 -1
- data/lib/ace/herdr.rb +5 -28
- metadata +64 -5
- data/lib/ace/herdr/organisms/protected_inbox.rb +0 -73
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Herdr
|
|
9
|
+
module Molecules
|
|
10
|
+
# The accepted native producer source shipped by this exact package.
|
|
11
|
+
# Provenance verifies bytes; deployment authorization stays with its owner.
|
|
12
|
+
class NativeSource
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
class ClosedObject < Hash
|
|
15
|
+
def []=(key, value)
|
|
16
|
+
raise FrozenError, "frozen native source object" if frozen?
|
|
17
|
+
raise Error, "duplicate native provenance field" if key?(key)
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
ASSETS = File.expand_path("../native_source", __dir__).freeze
|
|
23
|
+
SOURCE_KEYS = %w[schema repository baseline_commit source_commit source_tree patch_file patch_sha256
|
|
24
|
+
cargo_lock_sha256 rust_toolchain_sha256 rust_version zig_version native_version protocol committer targets].freeze
|
|
25
|
+
BUILD_KEYS = %w[target profile rust_version zig_version tools_sha256].freeze
|
|
26
|
+
RECEIPT_KEYS = %w[schema selection_sha256 source build artifact].freeze
|
|
27
|
+
TARGETS = %w[x86_64-unknown-linux-musl aarch64-unknown-linux-musl].freeze
|
|
28
|
+
SHA256 = /\A[0-9a-f]{64}\z/
|
|
29
|
+
GIT_SHA = /\A[0-9a-f]{40}\z/
|
|
30
|
+
VERSION = /\A\d+\.\d+\.\d+\z/
|
|
31
|
+
MAX_ARTIFACT_BYTES = 256 * 1024 * 1024
|
|
32
|
+
|
|
33
|
+
attr_reader :selection, :selection_sha256, :selection_path, :patch_path
|
|
34
|
+
|
|
35
|
+
def initialize(assets: ASSETS)
|
|
36
|
+
@selection_path = File.join(File.expand_path(assets), "selection.json")
|
|
37
|
+
bytes = read_regular(@selection_path, 65_536)
|
|
38
|
+
@selection = parse(bytes)
|
|
39
|
+
validate_selection!
|
|
40
|
+
@patch_path = File.join(File.dirname(@selection_path), @selection.fetch("patch_file"))
|
|
41
|
+
patch = read_regular(@patch_path, 1_048_576)
|
|
42
|
+
raise Error, "native source patch digest mismatch" unless digest(patch) == @selection.fetch("patch_sha256")
|
|
43
|
+
@selection_sha256 = digest(bytes).freeze
|
|
44
|
+
freeze_data(@selection)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def description
|
|
48
|
+
{"selection_path" => @selection_path, "selection_sha256" => @selection_sha256, "source" => @selection}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def verify_artifact(directory:)
|
|
52
|
+
directory = File.expand_path(directory)
|
|
53
|
+
info = File.lstat(directory)
|
|
54
|
+
raise Error, "native artifact directory must be regular" unless info.directory? && !info.symlink?
|
|
55
|
+
raise Error, "unexpected native artifact files" unless Dir.children(directory).sort == %w[herdr provenance.json]
|
|
56
|
+
receipt_bytes = read_regular(File.join(directory, "provenance.json"), 65_536)
|
|
57
|
+
receipt = parse(receipt_bytes)
|
|
58
|
+
validate_receipt!(receipt)
|
|
59
|
+
artifact = read_regular(File.join(directory, "herdr"), MAX_ARTIFACT_BYTES)
|
|
60
|
+
validate_elf!(artifact, receipt.fetch("build").fetch("target"))
|
|
61
|
+
expected = receipt.fetch("artifact")
|
|
62
|
+
unless artifact.bytesize == expected.fetch("size") && digest(artifact) == expected.fetch("sha256")
|
|
63
|
+
raise Error, "native artifact bytes do not match provenance"
|
|
64
|
+
end
|
|
65
|
+
freeze_data(receipt)
|
|
66
|
+
{"provenance_sha256" => digest(receipt_bytes), "selection_sha256" => @selection_sha256,
|
|
67
|
+
"source" => @selection, "build" => receipt.fetch("build"), "artifact" => expected}
|
|
68
|
+
rescue JSON::ParserError, SystemCallError, IOError => error
|
|
69
|
+
raise Error, "native artifact unavailable: #{error.class}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def artifact_record(bytes:, target:)
|
|
73
|
+
validate_elf!(bytes, target)
|
|
74
|
+
{"file" => "herdr", "size" => bytes.bytesize, "sha256" => digest(bytes)}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def receipt(build:, artifact:)
|
|
78
|
+
data = {"schema" => "ace.herdr.native-build/v1", "selection_sha256" => @selection_sha256,
|
|
79
|
+
"source" => @selection, "build" => build, "artifact" => artifact}
|
|
80
|
+
validate_receipt!(data)
|
|
81
|
+
data
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def read_regular(path, limit)
|
|
85
|
+
before = File.lstat(path)
|
|
86
|
+
raise Error, "native source input must be a regular file" unless before.file? && !before.symlink?
|
|
87
|
+
File.open(path, File::RDONLY | File::NOFOLLOW) do |input|
|
|
88
|
+
opened = input.stat
|
|
89
|
+
unless opened.file? && [opened.dev, opened.ino] == [before.dev, before.ino]
|
|
90
|
+
raise Error, "native source input changed while opening"
|
|
91
|
+
end
|
|
92
|
+
bytes = input.read(limit + 1)
|
|
93
|
+
raise Error, "native source input exceeds bound" if bytes.bytesize > limit
|
|
94
|
+
after = input.stat
|
|
95
|
+
unless [opened.size, opened.mtime, opened.ctime] == [after.size, after.mtime, after.ctime]
|
|
96
|
+
raise Error, "native source input changed while reading"
|
|
97
|
+
end
|
|
98
|
+
bytes
|
|
99
|
+
end
|
|
100
|
+
rescue SystemCallError, IOError => error
|
|
101
|
+
raise Error, "native source input unavailable: #{error.class}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def digest(bytes)
|
|
105
|
+
Digest::SHA256.hexdigest(bytes)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def parse(bytes)
|
|
111
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
112
|
+
raise Error, "invalid native source UTF-8" unless text.valid_encoding?
|
|
113
|
+
JSON.parse(text, object_class: ClosedObject, create_additions: false,
|
|
114
|
+
max_nesting: 16, allow_comments: false, allow_duplicate_key: false)
|
|
115
|
+
rescue JSON::ParserError, EncodingError
|
|
116
|
+
raise Error, "invalid native source JSON"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def fields!(data, expected)
|
|
120
|
+
unless data.is_a?(Hash) && data.keys.sort == expected.sort
|
|
121
|
+
raise Error, "invalid native source fields"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def string!(value, pattern)
|
|
126
|
+
raise Error, "invalid native source value" unless value.is_a?(String) && pattern.match?(value)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def validate_selection!(selection = @selection)
|
|
130
|
+
fields!(selection, SOURCE_KEYS)
|
|
131
|
+
raise Error, "unsupported native source schema" unless selection["schema"] == "ace.herdr.native-source/v1"
|
|
132
|
+
unless selection["repository"] == "https://github.com/herdrdev/herdr.git" && selection["patch_file"] == "guarded-prompt.patch"
|
|
133
|
+
raise Error, "unsupported native source selection"
|
|
134
|
+
end
|
|
135
|
+
%w[baseline_commit source_commit source_tree].each { |key| string!(selection[key], GIT_SHA) }
|
|
136
|
+
%w[patch_sha256 cargo_lock_sha256 rust_toolchain_sha256].each { |key| string!(selection[key], SHA256) }
|
|
137
|
+
%w[rust_version zig_version native_version].each { |key| string!(selection[key], VERSION) }
|
|
138
|
+
unless selection["protocol"].is_a?(Integer) && selection["protocol"].positive? && selection["targets"] == TARGETS
|
|
139
|
+
raise Error, "unsupported native target/protocol"
|
|
140
|
+
end
|
|
141
|
+
committer = selection["committer"]
|
|
142
|
+
fields!(committer, %w[name email date])
|
|
143
|
+
%w[name email].each { |key| string!(committer[key], /\A[^\x00-\x1f\x7f]{1,255}\z/) }
|
|
144
|
+
string!(committer["date"], /\A\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d[+-]\d\d:\d\d\z/)
|
|
145
|
+
Time.iso8601(committer["date"])
|
|
146
|
+
rescue ArgumentError
|
|
147
|
+
raise Error, "invalid native source committer date"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def validate_elf!(bytes, target)
|
|
151
|
+
raise Error, "unsupported native artifact target" unless TARGETS.include?(target)
|
|
152
|
+
machine = target.start_with?("x86_64-") ? 62 : 183
|
|
153
|
+
unless bytes.bytesize >= 64 && bytes.byteslice(0, 7) == "\x7fELF\x02\x01\x01".b &&
|
|
154
|
+
[2, 3].include?(bytes.byteslice(16, 2).unpack1("v")) && bytes.byteslice(18, 2).unpack1("v") == machine
|
|
155
|
+
raise Error, "native artifact is not the selected Linux executable architecture"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def validate_receipt!(data)
|
|
160
|
+
fields!(data, RECEIPT_KEYS)
|
|
161
|
+
validate_selection!(data["source"])
|
|
162
|
+
unless data["schema"] == "ace.herdr.native-build/v1" && data["selection_sha256"] == @selection_sha256 && data["source"] == @selection
|
|
163
|
+
raise Error, "native build source selection mismatch"
|
|
164
|
+
end
|
|
165
|
+
build = data["build"]
|
|
166
|
+
fields!(build, BUILD_KEYS)
|
|
167
|
+
unless @selection["targets"].include?(build["target"]) && build["profile"] == "release" &&
|
|
168
|
+
build["rust_version"] == @selection["rust_version"] && build["zig_version"] == @selection["zig_version"]
|
|
169
|
+
raise Error, "native build toolchain/target mismatch"
|
|
170
|
+
end
|
|
171
|
+
fields!(build["tools_sha256"], %w[cargo rustc zig linker])
|
|
172
|
+
build["tools_sha256"].each_value { |value| string!(value, SHA256) }
|
|
173
|
+
artifact = data["artifact"]
|
|
174
|
+
fields!(artifact, %w[file size sha256])
|
|
175
|
+
unless artifact["file"] == "herdr" && artifact["size"].is_a?(Integer) && artifact["size"].positive? && artifact["size"] <= MAX_ARTIFACT_BYTES
|
|
176
|
+
raise Error, "invalid native build artifact"
|
|
177
|
+
end
|
|
178
|
+
string!(artifact["sha256"], SHA256)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def freeze_data(value)
|
|
182
|
+
value.each { |key, item| freeze_data(key); freeze_data(item) } if value.is_a?(Hash)
|
|
183
|
+
value.each { |item| freeze_data(item) } if value.is_a?(Array)
|
|
184
|
+
value.freeze
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "tmpdir"
|
|
3
|
+
require "ace/runtime/molecules/protected_task_context_entry"
|
|
4
|
+
require_relative "bounded_process"
|
|
5
|
+
require_relative "inbox_context_store"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Herdr
|
|
9
|
+
module Molecules
|
|
10
|
+
# Existing immutable entry owns discovery; the endpoint still authorizes effects.
|
|
11
|
+
class ProtectedInboxSelection
|
|
12
|
+
TOKEN = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/
|
|
13
|
+
def initialize(entry_owner: Ace::Runtime::Molecules::ProtectedTaskContextEntry.new,
|
|
14
|
+
runner: BoundedProcess, uid: Process.uid, env: ENV)
|
|
15
|
+
@owner, @runner, @uid, @env = entry_owner, runner, uid, env
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def with(options)
|
|
19
|
+
downstream_started = false
|
|
20
|
+
selected = options.values_at(:project, :mapping, :inbox_context, :claim_generation, :assignment).any? { |item| !item.nil? } ||
|
|
21
|
+
%w[ACE_ASSIGN_PROJECT_ID ACE_ASSIGN_LAUNCH_MAPPING ACE_ASSIGN_INBOX_CONTEXT_ID].any? { |key| !@env[key].to_s.empty? }
|
|
22
|
+
@owner.with do |pin|
|
|
23
|
+
unless pin
|
|
24
|
+
raise ValidationError, "protected inbox entry is absent" if selected
|
|
25
|
+
downstream_started = true
|
|
26
|
+
next yield nil
|
|
27
|
+
end
|
|
28
|
+
principal = invoke(pin, ["authority", "inbox-context-principal"], 1024)
|
|
29
|
+
object!(principal, %w[schema uid protected_participant])
|
|
30
|
+
unless principal["schema"] == "ace.assign.inbox-context-principal/v1" && principal["uid"] == @uid &&
|
|
31
|
+
principal["uid"].is_a?(Integer) && [true, false].include?(principal["protected_participant"])
|
|
32
|
+
raise ValidationError, "protected inbox actual caller differs"
|
|
33
|
+
end
|
|
34
|
+
unless principal.fetch("protected_participant") || selected
|
|
35
|
+
downstream_started = true
|
|
36
|
+
next yield nil
|
|
37
|
+
end
|
|
38
|
+
keys = {project: "ACE_ASSIGN_PROJECT_ID", mapping: "ACE_ASSIGN_LAUNCH_MAPPING", inbox_context: "ACE_ASSIGN_INBOX_CONTEXT_ID"}
|
|
39
|
+
selectors = keys.to_h do |key, hint_key|
|
|
40
|
+
value, hint = options[key], @env[hint_key].to_s
|
|
41
|
+
unless value.is_a?(String) && TOKEN.match?(value) && (hint.empty? || hint == value)
|
|
42
|
+
raise ValidationError, "explicit protected inbox selectors differ"
|
|
43
|
+
end
|
|
44
|
+
[key, value]
|
|
45
|
+
end
|
|
46
|
+
args = selectors.flat_map { |key, value| ["--#{key.to_s.tr('_', '-')}", value] }
|
|
47
|
+
result = invoke(pin, ["authority", "inbox-context-selection", *args], 16_384)
|
|
48
|
+
object!(result, %w[schema uid project_id mapping_id inbox_context_id descriptor history context authority])
|
|
49
|
+
unless result["schema"] == "ace.assign.inbox-context-selection/v1" && result["uid"] == @uid &&
|
|
50
|
+
result.values_at("project_id", "mapping_id", "inbox_context_id") == selectors.values
|
|
51
|
+
raise ValidationError, "protected inbox selected association differs"
|
|
52
|
+
end
|
|
53
|
+
%w[descriptor history].each do |field|
|
|
54
|
+
reference = result.fetch(field)
|
|
55
|
+
object!(reference, %w[path bytes sha256])
|
|
56
|
+
@owner.validate_pin!({"manifest" => reference, "wrapper" => reference})
|
|
57
|
+
end
|
|
58
|
+
context = result.fetch("context")
|
|
59
|
+
object!(context, %w[control_socket_path owner_credentials native_mapping_id])
|
|
60
|
+
endpoint!(context.fetch("control_socket_path")); credentials!(context.fetch("owner_credentials"))
|
|
61
|
+
raise ValidationError, "protected inbox native mapping differs" unless context["native_mapping_id"].is_a?(String) && TOKEN.match?(context["native_mapping_id"])
|
|
62
|
+
authority = result.fetch("authority")
|
|
63
|
+
object!(authority, %w[authority_id socket_path uid gid groups])
|
|
64
|
+
endpoint!(authority.fetch("socket_path")); credentials!(authority.slice("uid", "gid", "groups"))
|
|
65
|
+
raise ValidationError, "protected inbox authority ID differs" unless authority["authority_id"].is_a?(String) && TOKEN.match?(authority["authority_id"])
|
|
66
|
+
downstream_started = true
|
|
67
|
+
yield result
|
|
68
|
+
end
|
|
69
|
+
rescue Ace::Runtime::Error, IOError, SystemCallError, Timeout::Error, BoundedProcess::PostLaunchError, KeyError, TypeError, ArgumentError
|
|
70
|
+
raise if downstream_started
|
|
71
|
+
raise ValidationError, "protected inbox selection is unavailable"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def invoke(pin, args, limit)
|
|
77
|
+
@owner.with_entry(pin) do |entry|
|
|
78
|
+
Dir.mktmpdir("ace-inbox-selector-", "/tmp") do |directory|
|
|
79
|
+
path = File.join(directory, "selector.json")
|
|
80
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |writer|
|
|
81
|
+
writer.write(JSON.generate(entry.pin.fetch("manifest")) + "\n"); writer.flush
|
|
82
|
+
File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |reader|
|
|
83
|
+
unless reader.stat.file? && reader.stat.uid == Process.uid && (reader.stat.mode & 0o077).zero? &&
|
|
84
|
+
[reader.stat.dev, reader.stat.ino, reader.stat.size] == [writer.stat.dev, writer.stat.ino, writer.stat.size]
|
|
85
|
+
raise ValidationError, "protected inbox selector handle differs"
|
|
86
|
+
end
|
|
87
|
+
File.unlink(path)
|
|
88
|
+
output = @runner.call(["/proc/self/fd/4", "-I", "-S", "-B", "-", *args], stdin_data: entry.body,
|
|
89
|
+
timeout_s: 30, output_limit: limit, stderr_limit: 16_384,
|
|
90
|
+
descriptor_mapping: {4 => entry.interpreter, 5 => reader}, environment: {}, chdir: "/", cleanup_group: true)
|
|
91
|
+
unless output.status&.success? && !output.oversized && output.stdout.is_a?(String) &&
|
|
92
|
+
output.stdout.end_with?("\n") && output.stdout.count("\n") == 1
|
|
93
|
+
raise ValidationError, "protected inbox entry failed or framing differs"
|
|
94
|
+
end
|
|
95
|
+
record = InboxContextStore.decode(output.stdout, limit: limit)
|
|
96
|
+
raise ValidationError, "protected inbox entry is not compact JSON" unless JSON.generate(record) + "\n" == output.stdout
|
|
97
|
+
record
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def object!(value, fields)
|
|
105
|
+
raise ValidationError, "protected inbox fields differ" unless value.is_a?(Hash) && value.keys.sort == fields.sort
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def endpoint!(value)
|
|
109
|
+
unless value.is_a?(String) && value.encoding == Encoding::UTF_8 && value.valid_encoding? &&
|
|
110
|
+
value.bytesize.between?(2, 4096) && value.start_with?("/") && !value.include?("\0") && File.expand_path(value) == value
|
|
111
|
+
raise ValidationError, "protected inbox endpoint differs"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def credentials!(value)
|
|
116
|
+
object!(value, %w[uid gid groups])
|
|
117
|
+
unless value.values_at("uid", "gid").all? { |number| number.is_a?(Integer) && number.positive? } &&
|
|
118
|
+
value["groups"].is_a?(Array) && value["groups"].size <= 64 &&
|
|
119
|
+
value["groups"].all? { |number| number.is_a?(Integer) && number.positive? } && value["groups"] == value["groups"].sort.uniq
|
|
120
|
+
raise ValidationError, "protected inbox principal differs"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
require "ace/runtime/molecules/protected_linux"
|
|
3
3
|
require "ace/runtime/molecules/protected_socket"
|
|
4
4
|
require "securerandom"
|
|
5
|
+
require_relative "guarded_native_origin"
|
|
5
6
|
|
|
6
7
|
module Ace
|
|
7
8
|
module Herdr
|
|
@@ -26,6 +27,129 @@ module Ace
|
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def request(method, params = {})
|
|
30
|
+
result = exchange(method, params)
|
|
31
|
+
unless result["result"].is_a?(Hash) && !result.key?("error")
|
|
32
|
+
raise Ace::Runtime::RuntimeUnavailableError, "native control response is invalid"
|
|
33
|
+
end
|
|
34
|
+
result.fetch("result")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Admission calls this before committing the durable prompt intent.
|
|
38
|
+
# Neither this method nor replay is permission to issue native input.
|
|
39
|
+
def prompt_preflight!(binding)
|
|
40
|
+
raise Ace::Runtime::RuntimeUnavailableError, "canonical original binding is unavailable" unless binding.is_a?(Hash)
|
|
41
|
+
original = binding.reject { |key, _| key == "guarded_origin" }
|
|
42
|
+
fresh = guarded_binding!(original)
|
|
43
|
+
unless fresh.fetch("guarded_origin") == guarded_origin!(binding)
|
|
44
|
+
raise Ace::Runtime::RuntimeUnavailableError, "canonical original guarded actor changed"
|
|
45
|
+
end
|
|
46
|
+
fresh.fetch("guarded_origin")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Explicit N2 capture keeps existing original scope binding bytes
|
|
50
|
+
# unchanged. The launch owner persists this separately in its canonical
|
|
51
|
+
# record data before issuing any steering permission.
|
|
52
|
+
def guarded_binding!(binding)
|
|
53
|
+
raise Ace::Runtime::RuntimeUnavailableError, "canonical original binding is unavailable" unless binding.is_a?(Hash)
|
|
54
|
+
original = binding.reject { |key, _| key == "guarded_origin" }
|
|
55
|
+
observed = observe(original.fetch("native_origin").merge("process_binding" => original))
|
|
56
|
+
info = request("pane.process_info", {"pane_id" => original.fetch("pane")}).fetch("process_info")
|
|
57
|
+
unless info["pane_id"] == original.fetch("pane") && info["shell_pid"] == observed.dig("process_identity", "pid") &&
|
|
58
|
+
info["guarded_prompt"] == true && info["guarded_input_drain"] == true
|
|
59
|
+
raise Ace::Runtime::RuntimeUnavailableError, "native original guarded capability is unavailable"
|
|
60
|
+
end
|
|
61
|
+
observed.merge("guarded_origin" => GuardedNativeOrigin.verify!(info.fetch("guarded_prompt_origin"),
|
|
62
|
+
terminal_id: observed.fetch("terminal_id"), child: observed.fetch("process_identity")))
|
|
63
|
+
rescue KeyError, TypeError
|
|
64
|
+
raise Ace::Runtime::RuntimeUnavailableError, "canonical original guarded actor is unavailable"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Only an already accepted canonical issue permit invokes this method.
|
|
68
|
+
# Unknown/lost transport results remain uncertain even if a local error
|
|
69
|
+
# occurred before some observed write; native phase is the only refusal
|
|
70
|
+
# evidence. Never return native agent/message fields or prompt bytes.
|
|
71
|
+
def prompt(binding:, text:)
|
|
72
|
+
submit_prompt(guarded_origin!(binding), prompt_body!(text, 16_384))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The admitted Inbox keeps its existing bounded payload contract while
|
|
76
|
+
# sharing the original actor guard and native submission transport.
|
|
77
|
+
def inbox_prompt(binding:, text:)
|
|
78
|
+
submit_prompt(guarded_origin!(binding), prompt_body!(text, 65_536))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def prompt_body!(text, limit)
|
|
82
|
+
body = text.dup.force_encoding(Encoding::UTF_8) if text.is_a?(String)
|
|
83
|
+
unless body && body.valid_encoding? && body.bytesize.between?(1, limit) && !body.match?(/\A[[:space:]]*\z/)
|
|
84
|
+
raise ArgumentError, "prompt text must be bounded nonblank UTF-8"
|
|
85
|
+
end
|
|
86
|
+
body
|
|
87
|
+
end
|
|
88
|
+
private :prompt_body!
|
|
89
|
+
|
|
90
|
+
# Called only by the admitted Inbox owner after durable queue/wake
|
|
91
|
+
# intent; this domain notification accepts no caller text or target.
|
|
92
|
+
def queue_wake(binding:)
|
|
93
|
+
submit_prompt(guarded_origin!(binding), "Check your native queued messages.")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def submit_prompt(origin, body)
|
|
97
|
+
frame = exchange("agent.prompt", {"target" => origin.fetch("terminal_id"), "text" => body, "expected_origin" => origin},
|
|
98
|
+
write_limit: 131_072, read_limit: 16_384)
|
|
99
|
+
result = frame["result"]
|
|
100
|
+
if frame.keys.sort == %w[id result] && result.is_a?(Hash) && result.keys.sort == %w[agent origin submission type] &&
|
|
101
|
+
result["type"] == "agent_prompted" && result["submission"] == "submitted" && result["origin"] == origin && result["agent"].is_a?(Hash)
|
|
102
|
+
GuardedNativeOrigin.verify!(result.fetch("origin"), terminal_id: origin.fetch("terminal_id"), child: origin.fetch("child"))
|
|
103
|
+
return {"outcome" => "submitted", "submission" => "submitted", "origin" => origin}
|
|
104
|
+
end
|
|
105
|
+
error = frame["error"]
|
|
106
|
+
codes = %w[guard_mismatch origin_unavailable origin_exited agent_blocked agent_not_ready target_missing submission_busy]
|
|
107
|
+
if frame.keys.sort == %w[error id] && error.is_a?(Hash) && error.keys.sort == %w[code message phase] &&
|
|
108
|
+
error["phase"] == "not_issued" && codes.include?(error["code"]) && error["message"].is_a?(String)
|
|
109
|
+
return {"outcome" => "not_issued", "phase" => "not_issued", "code" => error.fetch("code"), "origin" => origin}
|
|
110
|
+
end
|
|
111
|
+
{"outcome" => "uncertain", "origin" => origin}
|
|
112
|
+
rescue Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError
|
|
113
|
+
{"outcome" => "uncertain", "origin" => origin}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Input drain authenticates the captured actor even after its original
|
|
117
|
+
# child exited. Reobserving a live child here would destroy that proof.
|
|
118
|
+
# Actor/server replacement or missing completion remains unconfirmed.
|
|
119
|
+
def inhibit_input(binding:)
|
|
120
|
+
origin = guarded_origin!(binding)
|
|
121
|
+
drain_input(origin)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def drain_input(origin)
|
|
125
|
+
frame = exchange("terminal.inhibit_input", {"target" => origin.fetch("terminal_id"), "expected_origin" => origin},
|
|
126
|
+
write_limit: 16_384, read_limit: 16_384)
|
|
127
|
+
result = frame["result"]
|
|
128
|
+
if frame.keys.sort == %w[id result] && result.is_a?(Hash) && result.keys.sort == %w[input_state origin pending_input type] &&
|
|
129
|
+
result["type"] == "terminal_input_drained" && result["origin"] == origin && result["input_state"] == "inhibited" &&
|
|
130
|
+
result["pending_input"].is_a?(Integer) && result["pending_input"].zero?
|
|
131
|
+
GuardedNativeOrigin.verify!(result.fetch("origin"), terminal_id: origin.fetch("terminal_id"), child: origin.fetch("child"))
|
|
132
|
+
return {"outcome" => "inhibited", "origin" => origin, "input_state" => "inhibited", "pending_input" => 0}
|
|
133
|
+
end
|
|
134
|
+
error = frame["error"]
|
|
135
|
+
if frame.keys.sort == %w[error id] && error.is_a?(Hash) && error.keys.sort == %w[code phase] &&
|
|
136
|
+
error["phase"] == "unconfirmed" && %w[invalid_params guard_mismatch origin_unavailable input_drain_unavailable].include?(error["code"])
|
|
137
|
+
return {"outcome" => "unconfirmed", "origin" => origin, "code" => error.fetch("code"), "phase" => "unconfirmed"}
|
|
138
|
+
end
|
|
139
|
+
{"outcome" => "unconfirmed", "origin" => origin}
|
|
140
|
+
rescue Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError
|
|
141
|
+
{"outcome" => "unconfirmed", "origin" => origin}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def guarded_origin!(binding)
|
|
145
|
+
raise Ace::Runtime::RuntimeUnavailableError, "canonical original guarded binding is unavailable" unless binding.is_a?(Hash)
|
|
146
|
+
GuardedNativeOrigin.verify!(binding.fetch("guarded_origin"), terminal_id: binding.fetch("terminal_id"),
|
|
147
|
+
child: binding.fetch("process_identity"))
|
|
148
|
+
rescue KeyError, TypeError
|
|
149
|
+
raise Ace::Runtime::RuntimeUnavailableError, "canonical original guarded binding is unavailable"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def exchange(method, params = {}, write_limit: wire::LIMIT, read_limit: wire::LIMIT)
|
|
29
153
|
verify!
|
|
30
154
|
native = @mapping.fetch("native")
|
|
31
155
|
wire.connect(native.fetch("socket_path")) do |socket|
|
|
@@ -35,13 +159,13 @@ module Ace
|
|
|
35
159
|
end
|
|
36
160
|
id = SecureRandom.hex(12)
|
|
37
161
|
deadline = wire.deadline
|
|
38
|
-
wire.write(socket, {"id" => id, "method" => method, "params" => params}, deadline: deadline)
|
|
39
|
-
result = wire.read(socket, deadline: deadline)
|
|
40
|
-
unless result.is_a?(Hash) && result["id"] == id
|
|
162
|
+
wire.write(socket, {"id" => id, "method" => method, "params" => params}, deadline: deadline, limit: write_limit)
|
|
163
|
+
result = wire.read(socket, deadline: deadline, limit: read_limit)
|
|
164
|
+
unless result.is_a?(Hash) && result["id"] == id
|
|
41
165
|
raise Ace::Runtime::RuntimeUnavailableError, "native control response is invalid"
|
|
42
166
|
end
|
|
43
167
|
verify!
|
|
44
|
-
result
|
|
168
|
+
result
|
|
45
169
|
end
|
|
46
170
|
rescue SystemCallError, IOError
|
|
47
171
|
raise Ace::Runtime::RuntimeUnavailableError, "native control outcome is unavailable"
|
|
@@ -125,6 +249,7 @@ module Ace
|
|
|
125
249
|
end
|
|
126
250
|
|
|
127
251
|
private
|
|
252
|
+
private :exchange, :submit_prompt, :drain_input
|
|
128
253
|
def wire
|
|
129
254
|
Ace::Runtime::Molecules::ProtectedSocket
|
|
130
255
|
end
|