ace-runtime 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a1da4bd15724f798d3cf8a885c8f992035904e8bc6d95fb7d3c6a87df2ee729a
4
- data.tar.gz: 18d24a182510e477b565a5a1ee91d7bc2367fcb4f017f67843335c5d27b2a266
3
+ metadata.gz: 1a5a447921ab8d6b478e41a38e6b56866977accc8a75250e394c89c6fa0ac658
4
+ data.tar.gz: efe75a6544cb3284a9bf544deaaa30975b60971a5c821799666502240442ec7f
5
5
  SHA512:
6
- metadata.gz: 3b09aa5a8962e7dedaabb1e0da2b0f9079e5b8d9d61015a72408ca66fff68b900fc75f458007ded17a41218a5148f1d0a0bf59c494f11f4784f2333b9bc079a7
7
- data.tar.gz: eedb9d9395625b1f0c788586f345e296f2c05e141b5935b25bd02af08cddf975dbf15853830b3b42977e309a05c60f852a798ed2c0e846b00e711b5d6c2f2cba
6
+ metadata.gz: 46343d278d6041fcd7f41c859207ee0303e28811d156b923bc195b88cf73aef852d3f44b50df62a90425f0cec8a942984504ca9a7c52e4cd9152ffc77679d87a
7
+ data.tar.gz: b61c518fb64562ddb1975be5b6b71242134f013ec80de029bf259c5d92acdd2572b428f60788e4e4578e7f5f2a59abb3e50ec8664eaa462ceb65482ce3b6857e
data/CHANGELOG.md CHANGED
@@ -6,6 +6,46 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-10-10
10
+
11
+ - Ship the protected task-context/worker entry and readiness source used by the current consumers; these required modules were absent from published 0.2.0. Ordinary agent execution remains the ace-llm terminal CLI, with no additional Codex service.
12
+
13
+
14
+ - Pin and type-check the original native server's network namespace throughout readiness observation; refuse namespace replacement before returning its identity.
15
+ - Verify dedicated Inbox context-service installations with the full existing isolation and activation profile, exact literal HOST-to-same-VIEW references, fixed interpreter commands and a 35-second stop deadline. Require native executable/dependency bytes and IPC resources to be exact members of the same complete installed profile.
16
+
17
+ - Expose selected staged boundary topology validation for installer source composition while retaining mandatory live authority socket verification for installed units.
18
+
19
+ ### Fixed
20
+
21
+ - Accept and validate the original workspace exclusion in the compact prepared worker release, keeping the native gate and authority producer closed schemas consistent.
22
+
23
+ - Verify protected ancestor directory identity and protection without treating unrelated sibling publication timestamps as selected-artifact mutation; regular-file metadata remains fully pinned.
24
+
25
+ - Return the protected task-context callback result only after unchanged artifact verification, preserving Bundle responses and raw context text while refusing post-callback mutation.
26
+
27
+ - Decode systemd InvocationID from its actual typed 16-byte D-Bus array, retaining canonical hexadecimal observations. Strictly verify the fixed manager pidfd-to-unit response and explicitly inherit only the held lifetime descriptor for that method.
28
+
29
+ - Preserve Ruby already-loaded builtin require no-ops only for the allowlisted startup features verified at first guard activation; later activation cannot grow that authority.
30
+
31
+ - Resolve guarded Ruby native-extension requests using the selected interpreter platform extension, including explicit `.so`/`.o` aliases, while authenticating only exact declared files.
32
+
33
+ - Reject duplicate JSON keys, invalid UTF-8, comments and excessive nesting in protected socket frames while preserving exact byte boundaries between successive frames.
34
+
35
+ - Authenticate the boot-selected private devpts backing and exact instance-local ptmx node before API exemption; shared host views refuse even when read-only. Verify the explicit unit projections and pinned contained ptmx link/node observations.
36
+
37
+ ### Added
38
+
39
+ - Launch the fixed worker through held interpreter and sealed wrapper descriptors, with bounded original artifact hashes and fixed OpenSSL digest initialization. Native execution remains an installed acceptance requirement.
40
+
41
+ - Resolve current network installation evidence through a fixed protected slot selection while retaining immutable static profile/producer intent and original historical references.
42
+
43
+ - Expose a scoped authenticated read-only duplicate of a retained artifact for exact inode handoff; verify before and after callbacks and close on every exit.
44
+
45
+ - Allow trusted installer code to select a bounded artifact count through `ProtectedArtifactSet.new(count_limit:)`; ordinary callers retain the 256-artifact default and both read interfaces enforce the same budget.
46
+
47
+ - Verify immutable per-boot original host baseline selections and the fixed readiness configuration/runtime load surface. Collect complete bounded server mount/IPC views through contained pinned descriptors and verify exact original backing plus the authenticated read-only authority socket projection. Trusted domain boot-proof production/refresh and installed effectiveness remain separate obligations.
48
+
9
49
  ## [0.2.0] - 2026-10-05
10
50
 
11
51
  ### Added
data/docs/usage.md CHANGED
@@ -232,6 +232,21 @@ represent the access ACL mask, so named non-root ACL users/groups cannot obtain
232
232
  write through it. Unknown filesystem models refuse. All ancestors are checked;
233
233
  no symlink, untrusted writable ancestor or mutable worker artifact qualifies.
234
234
  Content bounds are 1MiB per artifact, 16MiB aggregate and 256 distinct artifacts.
235
+ Trusted installer code verifying a larger explicitly selected source closure can
236
+ construct `ProtectedArtifactSet.new(count_limit: 4096)`; the value must be a
237
+ positive integer supplied by trusted code, never copied from an artifact or
238
+ request. Existing `file_limit:` and `total_limit:` budgets still apply. Mixed
239
+ `read!`/`read_path!` calls share the count; repeating a retained reference spends
240
+ no additional entry, and the first new artifact beyond the limit refuses.
241
+ Trusted source installers can use `with_readonly_handle!(reference)` inside an
242
+ active `ProtectedArtifactSet#with` callback to hand off the authenticated retained
243
+ regular file. It yields a read-only duplicate without reopening the selected
244
+ pathname, verifies the held set before and after the callback, and closes the
245
+ duplicate on success or error. No descriptor or maintenance authority survives
246
+ the callback. Existing reference/budget/root protection rules still apply.
247
+
248
+ Network evidence verifiers retain their default budgets. Held descriptors also
249
+ require sufficient process file-descriptor capacity in the installer environment.
235
250
  The profile's 256-entry bound counts its four outer collections only; nested
236
251
  ranges, ports and helper/configuration refs are bounded by the profile bytes and
237
252
  separate artifact graph limits. Strict JSON parsing requires the declared
@@ -255,3 +270,43 @@ Root installation lifetime, private admission CAS, stage joins,
255
270
  namespace containment and maintenance ordering belong to their respective
256
271
  installed owners; this verifier does not implement them or supply native,
257
272
  packet-filter or installed acceptance by itself.
273
+
274
+ ### Original boot proof and fixed live readiness views
275
+
276
+ `ExecutionBootBaseline#select!(expected:)` authenticates the fixed protected
277
+ per-slot publication pointer at provisioning and returns its immutable proof
278
+ reference. `#verify!(selection:, expected:)` reads only that original reference,
279
+ joining whole-file bytes, slot, original boot, mapping digest, root PID1 birth
280
+ context and exact original network installer artifact. It never discovers an old
281
+ namespace or substitutes a current pointer. Proof JSON is closed and at most
282
+ 16KiB. Trusted host capture, per-boot refresh and cold-start admission inhibition
283
+ remain domain gad.8/gad.b producer obligations.
284
+
285
+ `ServerResourceObservation` uses the same-UID native server's pinned root and
286
+ namespace descriptors without entering or changing a namespace. It reports the
287
+ complete mount table (at most256 rows), fixed API/tmp view mount IDs obtained from
288
+ contained O_PATH descriptors, server/hook IPC identities and the exact authority
289
+ socket identity. It also holds the original server’s `/proc/<pid>/ns/net` object,
290
+ requires the nsfs network-namespace type and rechecks its identity after collection.
291
+ The report includes that observed `network_namespace_identity`; it never substitutes
292
+ the hook’s namespace or a configured namespace identity. The private report shares
293
+ a65,536-byte ceiling. Mount path
294
+ prefixes, typed isolation flags and filesystem names alone do not prove live
295
+ isolation. `KernelViewTopology` verifies the fixed supported profile, read-only
296
+ API views, private IPC host difference and exact writable original backing;
297
+ unknown writable aliases and even declared writable API descendants refuse.
298
+
299
+ The fixed unit exposes only the selected read-only source-equals-view authority
300
+ socket and has `BindLogSockets=false`. Protected endpoint type/owner/incarnation,
301
+ contained view identity and authenticated peer remain separate required joins.
302
+ These observations supply admission evidence, not terminality or installed
303
+ effectiveness.
304
+ The supported devpts profile additionally requires an authenticated per-slot
305
+ `newinstance` backing, distinct from original host devpts. The trusted boot
306
+ baseline pins original host and selected instance identities plus the exact host
307
+ `pts/ptmx` link. The unit binds the selected root at `/dev/pts` and its read-only
308
+ ptmx node at `/dev/pts/ptmx`; the contained observer verifies the unchanged link,
309
+ character device and actual mount identity. `PrivateDevices` alone cannot prove
310
+ this isolation. Shared host RO/RW views and another slot's backing refuse.
311
+ gad.8 provisioning and gad.b refresh must deliver the selected source before
312
+ activation; controlled tests do not establish installed effectiveness.
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Runtime
5
+ module Molecules
6
+ # Private specialization of the SAME immutable/effective installation owner.
7
+ class ExecutionUnitInstallation
8
+ CODEX_TOKEN = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/.freeze
9
+ CODEX_INTENT_FIELDS = %w[codex credentials cwd dependencies environment home inbox_context_id native_mapping_id project_id schema socket_gid socket_path thread_configuration unit_name].freeze
10
+
11
+ private
12
+
13
+ def initialize_codex_runtime(service, reference, mapping, mapping_id, authority, files)
14
+ unless service.is_a?(Hash) && service.keys.sort == %w[boundary_manifest execution_scope unit_manifest] &&
15
+ mapping.is_a?(Hash) && mapping_id.is_a?(String) && SystemdScopeManager::UNIT.match?(mapping_id)
16
+ raise RuntimeUnavailableError, "Codex service selection differs"
17
+ end
18
+ [reference, *service.values_at("unit_manifest", "boundary_manifest")].each { |ref| codex_reference!(ref, 65_536) }
19
+ bytes = files.read(reference.fetch("path"), limit: reference.fetch("bytes"))
20
+ unless bytes.bytesize == reference.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == reference.fetch("sha256")
21
+ raise RuntimeUnavailableError, "Codex intent bytes differ"
22
+ end
23
+ intent = JSON.parse(bytes, create_additions: false, max_nesting: 32, allow_duplicate_key: false, allow_comments: false)
24
+ unless intent.is_a?(Hash) && intent.keys.sort == CODEX_INTENT_FIELDS.sort &&
25
+ intent["schema"] == "lab.codex-runtime-intent/v1" &&
26
+ intent.values_at("project_id", "native_mapping_id", "cwd") == [mapping.fetch("project_id"), mapping_id, mapping.fetch("worker_cwd")] &&
27
+ intent["unit_name"].is_a?(String) && SystemdScopeManager::UNIT.match?(intent["unit_name"]) && intent["unit_name"].end_with?(".service") &&
28
+ intent["credentials"] == {"uid" => mapping.fetch("worker_uid"), "gid" => mapping.fetch("worker_gid"), "groups" => mapping.fetch("worker_groups")}
29
+ raise RuntimeUnavailableError, "Codex intent original mapping differs"
30
+ end
31
+ credentials = intent.fetch("credentials")
32
+ unless credentials.values_at("uid", "gid").all? { |id| id.is_a?(Integer) && id.positive? && id <= (1 << 32) - 2 } &&
33
+ credentials["groups"].is_a?(Array) && credentials["groups"].size <= 64 &&
34
+ credentials["groups"] == credentials["groups"].sort.uniq &&
35
+ credentials["groups"].all? { |id| id.is_a?(Integer) && id.between?(0, (1 << 32) - 2) }
36
+ raise RuntimeUnavailableError, "Codex original native credentials differ"
37
+ end
38
+ original = mapping.fetch("execution_scope")
39
+ scope = service.fetch("execution_scope")
40
+ changes = %w[service_unit unit_manifest_sha256]
41
+ unless scope.is_a?(Hash) && scope.keys.sort == %w[backend boundary_manifest_sha256 network_namespace_path root_directory runtime_directory service_unit slice_unit slot_id unit_manifest_sha256] &&
42
+ scope["backend"] == "linux_systemd_cgroup_v2" && scope.keys.sort == original.keys.sort &&
43
+ scope.reject { |key, _| changes.include?(key) } == original.reject { |key, _| changes.include?(key) } &&
44
+ scope["service_unit"] == intent.fetch("unit_name") && scope["service_unit"] != original.fetch("service_unit") &&
45
+ scope["unit_manifest_sha256"] == service.fetch("unit_manifest").fetch("sha256") &&
46
+ scope["boundary_manifest_sha256"] == service.fetch("boundary_manifest").fetch("sha256")
47
+ raise RuntimeUnavailableError, "Codex service expands original containment"
48
+ end
49
+ codex_reference!(intent.fetch("codex"), 268_435_456)
50
+ dependencies = intent.fetch("dependencies")
51
+ unless dependencies.is_a?(Array) && dependencies.size <= 506 && ([intent.fetch("codex")] + dependencies).map { |ref| ref.fetch("path") }.uniq.size == dependencies.size + 1
52
+ raise RuntimeUnavailableError, "Codex selected dependency inventory differs"
53
+ end
54
+ dependencies.each { |ref| codex_reference!(ref, 268_435_456) }
55
+ unless ([intent.fetch("codex")] + dependencies).sum { |ref| ref.fetch("bytes") } <= 268_435_456 &&
56
+ path?(intent["cwd"]) && path?(intent["home"]) && path?(intent["socket_path"]) &&
57
+ intent["socket_path"].encoding == Encoding::UTF_8 && intent["socket_path"].valid_encoding? && intent["socket_path"].bytesize <= 107 &&
58
+ intent["socket_gid"].is_a?(Integer) && intent["socket_gid"].between?(1, (1 << 32) - 2) &&
59
+ intent["inbox_context_id"].is_a?(String) && CODEX_TOKEN.match?(intent["inbox_context_id"]) &&
60
+ intent["thread_configuration"].is_a?(Hash) && intent["thread_configuration"].keys.sort == %w[approval_policy model sandbox] &&
61
+ intent["thread_configuration"].values_at("approval_policy", "sandbox") == ["never", "danger-full-access"] &&
62
+ intent["thread_configuration"]["model"].is_a?(String) && CODEX_TOKEN.match?(intent["thread_configuration"]["model"])
63
+ raise RuntimeUnavailableError, "Codex runtime input bounds differ"
64
+ end
65
+ environment = intent.fetch("environment")
66
+ unless environment.is_a?(Hash) && environment.size <= 32 && environment.all? { |key, value|
67
+ NATIVE_ENVIRONMENT.include?(key) && value.is_a?(String) && value.encoding == Encoding::UTF_8 &&
68
+ value.valid_encoding? && value.bytesize <= 4096 && !value.include?("\0") } &&
69
+ (!environment.key?("HOME") || environment["HOME"] == intent.fetch("home"))
70
+ raise RuntimeUnavailableError, "Codex runtime environment differs"
71
+ end
72
+ @codex_service, @codex_intent = immutable_selection(service), immutable_selection(intent)
73
+ @codex_intent_reference, @codex_authority = immutable_selection(reference), immutable_selection(authority)
74
+ @scope, @files = immutable_selection(scope), files
75
+ @worker_uid, @worker_gid, @codex_groups = credentials.values_at("uid", "gid", "groups")
76
+ @codex_groups = immutable_selection(@codex_groups)
77
+ rescue KeyError, TypeError, NoMethodError, ArgumentError, JSON::ParserError
78
+ raise RuntimeUnavailableError, "Codex installation selection is incomplete"
79
+ end
80
+
81
+ def codex_reference!(ref, limit)
82
+ unless ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] && path?(ref["path"]) &&
83
+ ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, limit) &&
84
+ ref["sha256"].is_a?(String) && ref["sha256"].match?(/\A[0-9a-f]{64}\z/)
85
+ raise RuntimeUnavailableError, "Codex immutable reference differs"
86
+ end
87
+ end
88
+
89
+ def codex_reference_bytes!(ref)
90
+ bytes = @files.read(ref.fetch("path"), limit: ref.fetch("bytes"))
91
+ unless bytes.bytesize == ref.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == ref.fetch("sha256")
92
+ raise RuntimeUnavailableError, "Codex selected artifact bytes differ"
93
+ end
94
+ bytes
95
+ end
96
+
97
+ def verify_codex_artifacts!(roles)
98
+ {"native_executable" => @codex_intent.fetch("codex"), "native_configuration" => @codex_intent_reference,
99
+ "boundary_manifest" => @codex_service.fetch("boundary_manifest")}.each do |role, ref|
100
+ artifact = roles.fetch(role).first
101
+ unless artifact.values_at("host_path", "view_path", "sha256") == ref.values_at("path", "path", "sha256") &&
102
+ codex_reference_bytes!(ref).bytesize == ref.fetch("bytes")
103
+ raise RuntimeUnavailableError, "Codex artifact differs from original selection"
104
+ end
105
+ end
106
+ unless roles.fetch("runtime_dependency", []).size == @codex_intent.fetch("dependencies").size
107
+ raise RuntimeUnavailableError, "Codex installed dependency inventory differs"
108
+ end
109
+ @codex_intent.fetch("dependencies").each do |ref|
110
+ selected = roles.fetch("runtime_dependency", []).select { |artifact| artifact.values_at("host_path", "view_path", "sha256") == ref.values_at("path", "path", "sha256") }
111
+ unless selected.one? && codex_reference_bytes!(ref).bytesize == ref.fetch("bytes")
112
+ raise RuntimeUnavailableError, "Codex selected dependency is not installed"
113
+ end
114
+ end
115
+ roles
116
+ end
117
+
118
+ def verify_codex_protocol!(service, artifacts)
119
+ executable = @codex_intent.fetch("codex").fetch("path")
120
+ verify_command!(service.fetch("ExecStartEx"), executable)
121
+ expected = @codex_intent.fetch("environment").merge("HOME" => @codex_intent.fetch("home")).map { |key, value| "#{key}=#{value}" }.sort
122
+ unless service.fetch("ExecStartEx").first[1] == [executable, "app-server", "--listen", "unix://" + @codex_intent.fetch("socket_path")] &&
123
+ service.fetch("ExecStartPostEx") == [] && service.fetch("Environment").sort == expected
124
+ raise RuntimeUnavailableError, "Codex unit commands differ from fixed startup protocol"
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require_relative "protected_artifact_set"
4
+
5
+ module Ace
6
+ module Runtime
7
+ module Molecules
8
+ # Content authentication over the existing trusted host installer proof.
9
+ # Actual original-host capture and boot refresh belong to gad.8/gad.b.
10
+ class ExecutionBootBaseline
11
+ LIMIT = 16_384
12
+ ID = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/
13
+ SHA = /\A[0-9a-f]{64}\z/
14
+ BOOT = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
15
+ EXPECTED = %w[boot_id deployment_digest installer_artifact slot_id].freeze
16
+ FIELDS = %w[boot_id deployment_digest host_devpts_identity host_ipc_namespace_identity host_ptmx_link original_host_context producer_artifact schema selected_devpts slot_id].freeze
17
+
18
+ def initialize(artifacts: ProtectedArtifactSet.new)
19
+ @artifacts = artifacts
20
+ end
21
+
22
+ # Fresh provisioning only. Never called for historical authentication.
23
+ def select!(expected:)
24
+ expected!(expected)
25
+ @artifacts.with do
26
+ path = "/etc/ace/execution-slots/#{expected.fetch('slot_id')}/boot-baseline-selection.json"
27
+ bytes, = @artifacts.read_path!(path, limit: LIMIT)
28
+ pointer = json!(bytes)
29
+ object!(pointer, %w[baseline schema slot_id])
30
+ refuse! unless pointer["schema"] == "ace.execution-boot-selection/v1" && pointer["slot_id"] == expected.fetch("slot_id")
31
+ selected = pointer.fetch("baseline")
32
+ baseline = authenticate!(selected, expected)
33
+ @artifacts.verify_unchanged!
34
+ immutable({"selection" => selected, "baseline" => baseline})
35
+ end
36
+ rescue KeyError, TypeError, NoMethodError, ArgumentError, IOError, SystemCallError, JSON::ParserError
37
+ refuse!
38
+ end
39
+
40
+ # Pure original-ref reader: expected boot comes from canonical lineage,
41
+ # not current discovery. Runtime owners separately check current boot.
42
+ def verify!(selection:, expected:)
43
+ expected!(expected)
44
+ @artifacts.with do
45
+ value = authenticate!(selection, expected)
46
+ @artifacts.verify_unchanged!
47
+ immutable(value)
48
+ end
49
+ rescue KeyError, TypeError, NoMethodError, ArgumentError, IOError, SystemCallError, JSON::ParserError
50
+ refuse!
51
+ end
52
+
53
+ private
54
+
55
+ def authenticate!(selected, expected)
56
+ reference!(selected, limit: LIMIT)
57
+ reference!(expected.fetch("installer_artifact"))
58
+ value = json!(@artifacts.read!(selected))
59
+ object!(value, FIELDS)
60
+ reference!(value.fetch("producer_artifact"))
61
+ unless value["schema"] == "ace.execution-boot-baseline/v1" &&
62
+ value.values_at("boot_id", "deployment_digest", "slot_id") == expected.values_at("boot_id", "deployment_digest", "slot_id") &&
63
+ value["producer_artifact"] == expected.fetch("installer_artifact")
64
+ refuse!
65
+ end
66
+ @artifacts.read!(expected.fetch("installer_artifact"))
67
+ identity = value.fetch("host_ipc_namespace_identity")
68
+ object!(identity, %w[device inode])
69
+ refuse! unless identity.values.all? { |number| number.is_a?(Integer) && number.positive? }
70
+ host = value.fetch("host_devpts_identity")
71
+ selected = value.fetch("selected_devpts")
72
+ object!(host, %w[device inode major_minor])
73
+ object!(selected, %w[device inode major_minor path ptmx_inode])
74
+ [host, selected].each do |instance|
75
+ refuse! unless %w[device inode].all? { |key| instance[key].is_a?(Integer) && instance[key].positive? } &&
76
+ instance["major_minor"].is_a?(String) && instance["major_minor"].match?(/\A(?:0|[1-9][0-9]*):(?:0|[1-9][0-9]*)\z/)
77
+ end
78
+ refuse! unless value["host_ptmx_link"] == "pts/ptmx" &&
79
+ selected["path"] == "/run/ace/execution-slots/#{expected.fetch('slot_id')}/devpts" &&
80
+ selected["device"] != host["device"] && selected["major_minor"] != host["major_minor"] &&
81
+ selected["ptmx_inode"].is_a?(Integer) && selected["ptmx_inode"].positive?
82
+ context = value.fetch("original_host_context")
83
+ object!(context, %w[gid pid started_at uid])
84
+ unless context.values_at("pid", "uid", "gid").all? { |number| number.is_a?(Integer) } &&
85
+ context.values_at("pid", "uid", "gid") == [1, 0, 0] && context["started_at"].is_a?(String) &&
86
+ context["started_at"].match?(/\Alinux:#{Regexp.escape(expected.fetch('boot_id'))}:(?:0|[1-9][0-9]*)\z/) &&
87
+ context["started_at"].bytesize <= 128
88
+ refuse!
89
+ end
90
+ value
91
+ end
92
+
93
+ def expected!(value)
94
+ object!(value, EXPECTED)
95
+ refuse! unless value["slot_id"].is_a?(String) && ID.match?(value["slot_id"]) &&
96
+ value["boot_id"].is_a?(String) && BOOT.match?(value["boot_id"]) &&
97
+ value["deployment_digest"].is_a?(String) && SHA.match?(value["deployment_digest"])
98
+ reference!(value.fetch("installer_artifact"))
99
+ end
100
+
101
+ def reference!(value, limit: ProtectedArtifactSet::LIMIT)
102
+ object!(value, %w[bytes path sha256])
103
+ refuse! unless value["path"].is_a?(String) && value["path"].bytesize.between?(1, 4096) &&
104
+ value["path"].start_with?("/") && !value["path"].include?("\0") && File.expand_path(value["path"]) == value["path"] &&
105
+ value["bytes"].is_a?(Integer) && value["bytes"].between?(1, limit) &&
106
+ value["sha256"].is_a?(String) && SHA.match?(value["sha256"])
107
+ end
108
+
109
+ def json!(bytes)
110
+ bytes = bytes.dup.force_encoding(Encoding::UTF_8)
111
+ refuse! unless bytes.bytesize.between?(1, LIMIT) && bytes.valid_encoding?
112
+ JSON.parse(bytes, create_additions: false, max_nesting: 8, allow_duplicate_key: false, allow_comments: false)
113
+ end
114
+
115
+ def object!(value, keys)
116
+ refuse! unless value.is_a?(Hash) && value.keys.sort == keys.sort
117
+ end
118
+
119
+ def immutable(value)
120
+ case value
121
+ when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
122
+ when Array then value.map { |item| immutable(item) }.freeze
123
+ when String then value.dup.freeze
124
+ else value
125
+ end
126
+ end
127
+
128
+ def refuse!
129
+ raise RuntimeUnavailableError, "authenticated original host boot baseline unavailable"
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require_relative "protected_artifact_set"
4
+
5
+ module Ace
6
+ module Runtime
7
+ module Molecules
8
+ # Current selection only. Original canonical bindings retain literal refs
9
+ # and authenticate through NetworkInstallationEvidence without this pointer.
10
+ class ExecutionNetworkSelection
11
+ LIMIT = 65_536
12
+ FIELDS = %w[installer_artifact policy_export profile report].freeze
13
+
14
+ def initialize(artifacts: ProtectedArtifactSet.new)
15
+ @artifacts = artifacts
16
+ end
17
+
18
+ # Shape validation confers no pointer access or network admission proof.
19
+ def self.validate_static!(selection, slot_id:)
20
+ new.send(:static!, selection, slot_id)
21
+ end
22
+
23
+ def select!(static_selection:, slot_id:)
24
+ static_selection = immutable(static_selection)
25
+ path = static!(static_selection, slot_id)
26
+ @artifacts.with do
27
+ bytes, = @artifacts.read_path!(path, limit: LIMIT)
28
+ bytes = bytes.dup.force_encoding(Encoding::UTF_8)
29
+ refuse! unless bytes.valid_encoding? && bytes.bytesize.between?(1, LIMIT)
30
+ pointer = JSON.parse(bytes, create_additions: false, max_nesting: 16, allow_duplicate_key: false, allow_comments: false)
31
+ object!(pointer, %w[schema selection slot_id])
32
+ refuse! unless pointer["schema"] == "ace.network-installation-selection/v1" && pointer["slot_id"] == slot_id
33
+ selection = pointer.fetch("selection")
34
+ object!(selection, FIELDS)
35
+ selection.each_value { |ref| reference!(ref) }
36
+ refuse! unless %w[profile installer_artifact].all? { |name| selection.fetch(name) == static_selection.fetch(name) }
37
+ selection.each_value { |ref| @artifacts.read!(ref) }
38
+ @artifacts.verify_unchanged!
39
+ immutable(selection)
40
+ end
41
+ rescue KeyError, TypeError, NoMethodError, ArgumentError, IOError, SystemCallError, JSON::ParserError, EncodingError
42
+ refuse!
43
+ end
44
+
45
+ private
46
+
47
+ def static!(selection, slot_id)
48
+ object!(selection, %w[current_selection_path installer_artifact profile])
49
+ refuse! unless slot_id.is_a?(String) && slot_id.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/)
50
+ path = "/etc/ace/execution-slots/#{slot_id}/network-installation-selection.json"
51
+ refuse! unless selection.fetch("current_selection_path") == path
52
+ %w[profile installer_artifact].each { |name| reference!(selection.fetch(name)) }
53
+ path
54
+ rescue KeyError, TypeError, NoMethodError, ArgumentError
55
+ refuse!
56
+ end
57
+
58
+ def reference!(value)
59
+ object!(value, %w[bytes path sha256])
60
+ path = value.fetch("path")
61
+ refuse! unless path.is_a?(String) && path.bytesize.between?(1, 4096) && path.start_with?("/") &&
62
+ !path.include?("\0") && File.expand_path(path) == path && value["bytes"].is_a?(Integer) &&
63
+ value["bytes"].between?(1, ProtectedArtifactSet::LIMIT) && value["sha256"].is_a?(String) &&
64
+ value["sha256"].match?(/\A[0-9a-f]{64}\z/)
65
+ end
66
+
67
+ def object!(value, keys)
68
+ refuse! unless value.is_a?(Hash) && value.keys.sort == keys.sort
69
+ end
70
+
71
+ def immutable(value)
72
+ case value
73
+ when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
74
+ when String then value.dup.freeze
75
+ else value
76
+ end
77
+ end
78
+
79
+ def refuse!
80
+ raise RuntimeUnavailableError, "protected current network selection unavailable"
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end