ace-runtime 0.1.1 → 0.2.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 +18 -0
- data/README.md +9 -0
- data/docs/usage.md +68 -0
- data/lib/ace/runtime/molecules/cgroup_observation.rb +148 -0
- data/lib/ace/runtime/molecules/execution_unit_installation.rb +309 -0
- data/lib/ace/runtime/molecules/linux_mount_info.rb +101 -0
- data/lib/ace/runtime/molecules/network_installation_evidence.rb +332 -0
- data/lib/ace/runtime/molecules/process_identity.rb +123 -0
- data/lib/ace/runtime/molecules/protected_artifact_set.rb +146 -0
- data/lib/ace/runtime/molecules/protected_linux.rb +118 -0
- data/lib/ace/runtime/molecules/protected_socket.rb +97 -0
- data/lib/ace/runtime/molecules/systemd_scope_manager.rb +323 -0
- data/lib/ace/runtime/testing/adapter_contract.rb +9 -0
- data/lib/ace/runtime/version.rb +1 -1
- data/lib/ace/runtime.rb +2 -0
- data/native/README.md +69 -0
- data/native/build-worker-gate +18 -0
- data/native/worker_gate.c +199 -0
- metadata +54 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a1da4bd15724f798d3cf8a885c8f992035904e8bc6d95fb7d3c6a87df2ee729a
|
|
4
|
+
data.tar.gz: 18d24a182510e477b565a5a1ee91d7bc2367fcb4f017f67843335c5d27b2a266
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3b09aa5a8962e7dedaabb1e0da2b0f9079e5b8d9d61015a72408ca66fff68b900fc75f458007ded17a41218a5148f1d0a0bf59c494f11f4784f2333b9bc079a7
|
|
7
|
+
data.tar.gz: eedb9d9395625b1f0c788586f345e296f2c05e141b5935b25bd02af08cddf975dbf15853830b3b42977e309a05c60f852a798ed2c0e846b00e711b5d6c2f2cba
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.0] - 2026-10-05
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Authenticate bounded root-produced network profile/report/trace artifacts with closed strict JSON schemas, exact context/check joins and held protected file descriptors. Missing or incomplete evidence refuses; effective network enforcement and immutable publication remain the domain installer’s responsibility.
|
|
14
|
+
|
|
15
|
+
- Verify fixed execution-unit artifact bytes and typed effective system-manager properties, including retained parent activation hierarchy, service enablement routes and exact native/readiness commands. Installation verification remains separate from live boundary and whole-scope proof.
|
|
16
|
+
|
|
17
|
+
- Add fixed system-systemd unit operations with bounded noninteractive jobs and descriptor-pinned cgroup-v2 population/membership observation. These primitives do not manufacture canonical scope proof or claim installed boundary acceptance.
|
|
18
|
+
|
|
19
|
+
- Ship the immutable native worker gate and protected Linux/socket primitives with exact process birth, pidfd exit, enforced Yama 2, empty capability sets and NoNewPrivs checks.
|
|
20
|
+
|
|
21
|
+
- Add read-only process ownership observation with PID birth identity, OS ancestry, unknown liveness and shared adapter contract coverage.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Parse genuine typed nsfs mount roots while rejecting them as backing-storage projections, and query activation properties through the correct fixed slice and service interfaces.
|
|
26
|
+
|
|
9
27
|
## [0.1.1] - 2026-10-04
|
|
10
28
|
|
|
11
29
|
### Fixed
|
data/README.md
CHANGED
|
@@ -18,6 +18,7 @@ Eleven operations, keyword-arg surfaces mirroring today's consumer calls:
|
|
|
18
18
|
|-----------|---------|
|
|
19
19
|
| Context | `runtime.context` → `{in_runtime:, session:, window:, pane:}` |
|
|
20
20
|
| Ensure window | `runtime.ensure_window(name:, root:, preset: nil)` — idempotent by normalized name; conflicting root/preset raises `WindowConflictError` |
|
|
21
|
+
| Process identity | `runtime.process_binding(pane:, caller_pid:)` → verified owner/native binding or nil (unknown); read-only, no authorization |
|
|
21
22
|
| Prepare pane | `runtime.prepare_pane(window:)` — splits when needed; target stays alive after a submitted command exits |
|
|
22
23
|
| Focus | `runtime.focus(window:)` |
|
|
23
24
|
| Send | `runtime.send(pane:, command: nil, items: [])` — ordered `{message: String}` / `{key: String}` entries |
|
|
@@ -118,3 +119,11 @@ adapter-authoring walkthrough.
|
|
|
118
119
|
Note: adapters intentionally define `send` (the intent operation),
|
|
119
120
|
which overrides `Object#send`. Use `__send__` for reflective dispatch
|
|
120
121
|
on adapter objects.
|
|
122
|
+
|
|
123
|
+
## Recoverable process ownership
|
|
124
|
+
|
|
125
|
+
`process_binding(pane:, caller_pid:)` identifies a caller's owner below the exact native retained shell. The returned string-keyed object binds `runtime`, `session`, `pane`, `shell_identity` and `process_identity`; each OS identity carries PID, UID, start time and host. Herdr additionally binds durable terminal ID, agent kind and immutable native agent session, and corroborates the owner against foreground process information. No command line arguments are stored.
|
|
126
|
+
|
|
127
|
+
An absent or non-native caller, retained shell without its owner, inaccessible OS facts, zombie or mismatched process returns nil. Native transport failures keep the standard typed error model. Consumers must compare the complete binding on each recovery observation. A process binding proves liveness only and grants no actor, service, effect or HITL authority. Unknown observation preserves resources; it does not prove all descendants stopped.
|
|
128
|
+
|
|
129
|
+
Process birth uses Linux boot ID with `/proc` start ticks or Darwin libproc microsecond birth. Whole-second `ps lstart` is never identity evidence. Unsupported/unreadable exact birth facilities return unknown. Capture brackets process metadata with birth reads, and native ownership revalidates the whole ancestor chain before returning it.
|
data/docs/usage.md
CHANGED
|
@@ -187,3 +187,71 @@ The reference implementation is `test/support/fake_runtime_adapter.rb`
|
|
|
187
187
|
in the ace-runtime gem; the in-gem suite
|
|
188
188
|
(`test/contract/adapter_contract_test.rb`) proves the battery against
|
|
189
189
|
it for both send profiles.
|
|
190
|
+
|
|
191
|
+
## Root-produced network installation evidence
|
|
192
|
+
|
|
193
|
+
The source-only `Ace::Runtime::Molecules::NetworkInstallationEvidence` consumer
|
|
194
|
+
checks the protected content selected by the installed scope owner:
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
result = Ace::Runtime::Molecules::NetworkInstallationEvidence.verify!(
|
|
198
|
+
selection: boundary_manifest.fetch("network_installation"),
|
|
199
|
+
expected: {
|
|
200
|
+
"slot_id" => slot_id,
|
|
201
|
+
"namespace_path" => installed_namespace_path,
|
|
202
|
+
"boot_id" => current_boot_id,
|
|
203
|
+
"namespace_identity" => {"device" => pinned_device, "inode" => pinned_inode},
|
|
204
|
+
"installer_artifact_sha256" => installed_producer_sha256
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
`selection` is exactly `{profile,policy_export,report,installer_artifact}`; each
|
|
210
|
+
reference is exactly `{path,sha256,bytes}`. All keys are strings, paths canonical
|
|
211
|
+
and absolute, SHA256 lowercase, and lengths positive integers. There is no
|
|
212
|
+
caller-uploaded evidence or caller-selected trust root. The namespace identity
|
|
213
|
+
above must come from the owner's actual held, authenticated nsfs network object;
|
|
214
|
+
the owner validates its type/current boot and keeps the namespace pinned through
|
|
215
|
+
admission. This verifier reads content artifacts and never opens/substitutes the
|
|
216
|
+
namespace, probes traffic or grants runtime networking privilege.
|
|
217
|
+
|
|
218
|
+
Successful output contains exactly `report_id`, `boot_id`, `slot_id`,
|
|
219
|
+
`namespace_path`, `namespace_identity`, `profile_sha256`, `policy_export_sha256`,
|
|
220
|
+
`report_sha256` and `installer_artifact_sha256`, deeply frozen. It authenticates
|
|
221
|
+
installation content, not actual enforcement from a summary boolean. Missing,
|
|
222
|
+
unreadable, malformed, conflicting, oversized or context-mismatched evidence
|
|
223
|
+
raises `Ace::Runtime::RuntimeUnavailableError`; the authority boundary exposes
|
|
224
|
+
that refusal as `evidence_unavailable` and retains public `invalid_input`
|
|
225
|
+
classification for malformed public selectors. No absent-evidence fallback.
|
|
226
|
+
|
|
227
|
+
The reader pins regular-file and directory descriptors for the complete graph,
|
|
228
|
+
checks exact lengths/digests and revalidates original objects/modes at completion.
|
|
229
|
+
It uses root ownership and no group/other write on supported Linux POSIX local
|
|
230
|
+
filesystems (`ext2`, `ext3`, `ext4`, `xfs`, `btrfs`, `tmpfs`): group mode bits
|
|
231
|
+
represent the access ACL mask, so named non-root ACL users/groups cannot obtain
|
|
232
|
+
write through it. Unknown filesystem models refuse. All ancestors are checked;
|
|
233
|
+
no symlink, untrusted writable ancestor or mutable worker artifact qualifies.
|
|
234
|
+
Content bounds are 1MiB per artifact, 16MiB aggregate and 256 distinct artifacts.
|
|
235
|
+
The profile's 256-entry bound counts its four outer collections only; nested
|
|
236
|
+
ranges, ports and helper/configuration refs are bounded by the profile bytes and
|
|
237
|
+
separate artifact graph limits. Strict JSON parsing requires the declared
|
|
238
|
+
`json >= 2.20, < 3` dependency to reject duplicate keys and comments explicitly.
|
|
239
|
+
|
|
240
|
+
The trusted domain installer owns complete effective policy/topology comparison,
|
|
241
|
+
independent correlated flow checks, immutable digest-bearing evidence filenames
|
|
242
|
+
and publication that preserves historical artifacts. The generic reference
|
|
243
|
+
schema specifies no basename grammar: this consumer checks selected content and
|
|
244
|
+
protection, and cannot establish publication history. Raw policy/topology/setup
|
|
245
|
+
and correlated observations remain required; unsupported or incomplete domain
|
|
246
|
+
checks must never produce a passing report. Domain source/installed review proves
|
|
247
|
+
those obligations. For each loopback tool, the fixed domain procedure inspects
|
|
248
|
+
the actual selected executable against its profile `artifact_sha256` and retains
|
|
249
|
+
the observation in the exact tool-target trace. The schema provides no executable
|
|
250
|
+
path/ref: generic ACE authenticates the trace/raw refs and digest format; it does
|
|
251
|
+
not compare a tool executable digest with observation bytes or invent a ref.
|
|
252
|
+
Domain acceptance must exercise tool-executable mismatch refusal.
|
|
253
|
+
|
|
254
|
+
Root installation lifetime, private admission CAS, stage joins,
|
|
255
|
+
namespace containment and maintenance ordering belong to their respective
|
|
256
|
+
installed owners; this verifier does not implement them or supply native,
|
|
257
|
+
packet-filter or installed acceptance by itself.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "linux_mount_info"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Runtime
|
|
8
|
+
module Molecules
|
|
9
|
+
# A live descriptor pins the parent object, never the reusable pathname.
|
|
10
|
+
# Population is an observation only: canonical admission seal and verified
|
|
11
|
+
# resource boundaries remain the authority owner's responsibility.
|
|
12
|
+
class CgroupObservation
|
|
13
|
+
ROOT = "/sys/fs/cgroup"
|
|
14
|
+
IDENTITY_FIELDS = %w[path mount_id filesystem_type device inode].freeze
|
|
15
|
+
|
|
16
|
+
class KernelFiles
|
|
17
|
+
def open_directory(path)
|
|
18
|
+
File.open(path, File::RDONLY | File::NOFOLLOW)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def read(path, limit:)
|
|
22
|
+
File.open(path, File::RDONLY | File::NOFOLLOW) do |file|
|
|
23
|
+
bytes = file.read(limit + 1)
|
|
24
|
+
raise RuntimeUnavailableError, "kernel scope observation is oversized" if bytes.bytesize > limit
|
|
25
|
+
bytes
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def mount_identity(handle)
|
|
30
|
+
info = read("/proc/self/fdinfo/#{handle.fileno}", limit: 4096)
|
|
31
|
+
matches = info.lines.filter_map { |line| /\Amnt_id:\s+([0-9]+)\s*\z/.match(line)&.[](1) }
|
|
32
|
+
raise RuntimeUnavailableError, "scope mount identity is unavailable" unless matches.size == 1
|
|
33
|
+
mount_id = Integer(matches.first, 10)
|
|
34
|
+
mount = LinuxMountInfo.new(read("/proc/self/mountinfo", limit: LinuxMountInfo::LIMIT)).by_id(mount_id)
|
|
35
|
+
mount.slice("mount_id", "filesystem_type", "root", "mountpoint")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(files: KernelFiles.new)
|
|
40
|
+
@files = files
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def pin(path, expected: nil)
|
|
44
|
+
validate_path!(path)
|
|
45
|
+
handle = @files.open_directory(path)
|
|
46
|
+
identity = identity_for(handle, path)
|
|
47
|
+
unless expected.nil? || valid_identity?(expected) && identity == expected
|
|
48
|
+
raise RuntimeUnavailableError, "retained scope object was replaced"
|
|
49
|
+
end
|
|
50
|
+
verify_path!(handle, identity)
|
|
51
|
+
{handle: handle, identity: identity.freeze}
|
|
52
|
+
rescue SystemCallError, IOError
|
|
53
|
+
handle&.close
|
|
54
|
+
raise RuntimeUnavailableError, "exact scope object cannot be pinned"
|
|
55
|
+
rescue Exception
|
|
56
|
+
handle&.close
|
|
57
|
+
raise
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def observe(pinned)
|
|
61
|
+
handle, identity = pinned.values_at(:handle, :identity)
|
|
62
|
+
unless handle && !handle.closed? && valid_identity?(identity) && identity_for(handle, identity.fetch("path")) == identity
|
|
63
|
+
raise RuntimeUnavailableError, "pinned scope identity changed"
|
|
64
|
+
end
|
|
65
|
+
verify_path!(handle, identity)
|
|
66
|
+
bytes = @files.read("/proc/self/fd/#{handle.fileno}/cgroup.events", limit: 4096)
|
|
67
|
+
fields = parse_events(bytes)
|
|
68
|
+
verify_path!(handle, identity)
|
|
69
|
+
{"cgroup_identity" => identity.dup, "populated" => fields.fetch("populated")}
|
|
70
|
+
rescue SystemCallError, IOError, KeyError
|
|
71
|
+
raise RuntimeUnavailableError, "retained scope cannot be observed"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Membership checks use the caller's independently pinned birth policy.
|
|
75
|
+
# A namespace-relative cgroup value must agree with the host parent;
|
|
76
|
+
# unresolved membership refuses rather than following another mount.
|
|
77
|
+
def member!(identity, pinned:, kernel:)
|
|
78
|
+
kernel.live!(identity)
|
|
79
|
+
observe(pinned)
|
|
80
|
+
bytes = @files.read("/proc/#{identity.fetch('pid')}/cgroup", limit: 4096)
|
|
81
|
+
paths = bytes.lines.filter_map { |line| /\A0::(\/[^\n]*)\n?\z/.match(line)&.[](1) }
|
|
82
|
+
parent = pinned.fetch(:identity).fetch("path").delete_prefix(ROOT)
|
|
83
|
+
unless paths.size == 1 && (paths.first == parent || paths.first.start_with?(parent + "/"))
|
|
84
|
+
raise RuntimeUnavailableError, "process is outside the exact execution scope"
|
|
85
|
+
end
|
|
86
|
+
kernel.live!(identity)
|
|
87
|
+
observe(pinned)
|
|
88
|
+
true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def parse_events(bytes)
|
|
92
|
+
unless bytes.is_a?(String) && bytes.bytesize.between?(1, 4096)
|
|
93
|
+
raise RuntimeUnavailableError, "cgroup population is unavailable"
|
|
94
|
+
end
|
|
95
|
+
fields = {}
|
|
96
|
+
bytes.each_line do |line|
|
|
97
|
+
match = /\A([a-z_]+) ([0-9]+)\n?\z/.match(line)
|
|
98
|
+
unless match && !fields.key?(match[1])
|
|
99
|
+
raise RuntimeUnavailableError, "cgroup events are malformed"
|
|
100
|
+
end
|
|
101
|
+
fields[match[1]] = Integer(match[2], 10)
|
|
102
|
+
end
|
|
103
|
+
unless [0, 1].include?(fields["populated"])
|
|
104
|
+
raise RuntimeUnavailableError, "cgroup population is not a positive kernel observation"
|
|
105
|
+
end
|
|
106
|
+
fields
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def valid_identity?(value)
|
|
112
|
+
value.is_a?(Hash) && value.keys.sort == IDENTITY_FIELDS.sort &&
|
|
113
|
+
value["path"].is_a?(String) && value["filesystem_type"] == "cgroup2" && %w[mount_id device inode].all? do |key|
|
|
114
|
+
value[key].is_a?(Integer) && value[key] >= 0
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def validate_path!(path)
|
|
119
|
+
unless path.is_a?(String) && path.bytesize <= 4096 && !path.include?("\0") &&
|
|
120
|
+
path.start_with?(ROOT + "/") && File.expand_path(path) == path && path != ROOT
|
|
121
|
+
raise RuntimeUnavailableError, "scope must be an exact parent below the unified cgroup root"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def identity_for(handle, path)
|
|
126
|
+
stat = handle.stat
|
|
127
|
+
mount = @files.mount_identity(handle)
|
|
128
|
+
unless stat.directory? && mount["filesystem_type"] == "cgroup2" &&
|
|
129
|
+
mount["mountpoint"] == ROOT && mount["root"] == "/"
|
|
130
|
+
raise RuntimeUnavailableError, "scope requires the host unified cgroup-v2 mount"
|
|
131
|
+
end
|
|
132
|
+
{"path" => path, "mount_id" => mount.fetch("mount_id"), "filesystem_type" => "cgroup2",
|
|
133
|
+
"device" => stat.dev, "inode" => stat.ino}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def verify_path!(handle, identity)
|
|
137
|
+
current = @files.open_directory(identity.fetch("path"))
|
|
138
|
+
unless identity_for(current, identity.fetch("path")) == identity && handle.stat.ino == current.stat.ino
|
|
139
|
+
raise RuntimeUnavailableError, "scope pathname no longer names its pinned object"
|
|
140
|
+
end
|
|
141
|
+
true
|
|
142
|
+
ensure
|
|
143
|
+
current&.close
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "etc"
|
|
6
|
+
require_relative "systemd_scope_manager"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Runtime
|
|
10
|
+
module Molecules
|
|
11
|
+
# Root-installed artifacts plus the system manager's effective properties.
|
|
12
|
+
# These establish installation identity, not effective runtime isolation;
|
|
13
|
+
# the same-User readiness action and live owner verify that separately.
|
|
14
|
+
class ExecutionUnitInstallation
|
|
15
|
+
SCHEMA = "ace.execution-unit-manifest/v1"
|
|
16
|
+
ROLES = %w[slice_fragment service_fragment unit_dropin native_executable native_configuration
|
|
17
|
+
readiness_executable bootstrap worker_executable runtime_dependency].freeze
|
|
18
|
+
REQUIRED_ROLES = %w[slice_fragment service_fragment native_executable native_configuration
|
|
19
|
+
readiness_executable bootstrap worker_executable].freeze
|
|
20
|
+
SERVICE_REQUIRED = {"Type" => "exec", "Restart" => "no", "RestartForceExitStatus" => [[], []],
|
|
21
|
+
"KillMode" => "control-group", "SendSIGKILL" => true, "Delegate" => false,
|
|
22
|
+
"ProtectControlGroups" => true, "NoNewPrivileges" => true, "CapabilityBoundingSet" => 0,
|
|
23
|
+
"AmbientCapabilities" => 0, "RestrictNamespaces" => 0, "PrivateIPC" => true,
|
|
24
|
+
"PrivateDevices" => true, "ProtectSystem" => "strict", "DynamicUser" => false,
|
|
25
|
+
"EnvironmentFiles" => [], "PassEnvironment" => [], "UnsetEnvironment" => [],
|
|
26
|
+
"StandardOutput" => "journal", "StandardError" => "journal", "RuntimeDirectoryPreserve" => "no",
|
|
27
|
+
"RuntimeDirectoryMode" => 0o700, "UMask" => 0o077, "RootImage" => "", "RootImageOptions" => [], "RootEphemeral" => false, "ExtensionDirectories" => [],
|
|
28
|
+
"ExtensionImages" => [], "MountImages" => [], "TemporaryFileSystem" => [], "InaccessiblePaths" => []}.freeze
|
|
29
|
+
EMPTY_ACTIVATION = %w[Requisite BindsTo PartOf Upholds OnFailure OnSuccess OnFailureOf OnSuccessOf
|
|
30
|
+
TriggeredBy Triggers PropagatesStopTo StopPropagatedFrom JoinsNamespaceOf].freeze
|
|
31
|
+
NATIVE_ENVIRONMENT = %w[HERDR_CONFIG_PATH HERDR_SOCKET_PATH HOME SHELL PATH LANG LC_ALL TERM
|
|
32
|
+
TMPDIR TMP TEMP XDG_CONFIG_HOME XDG_DATA_HOME XDG_STATE_HOME XDG_CACHE_HOME XDG_RUNTIME_DIR CODEX_HOME CLAUDE_CONFIG_DIR].freeze
|
|
33
|
+
IMPLICIT_NAMESPACE_ROOTS = %w[/dev /proc /sys /run /tmp /var/tmp].freeze
|
|
34
|
+
EMPTY_EXEC = %w[ExecConditionEx ExecStartPreEx ExecReloadEx ExecStopEx ExecStopPostEx].freeze
|
|
35
|
+
|
|
36
|
+
class Files
|
|
37
|
+
def read(path, limit:)
|
|
38
|
+
ProtectedSocket.root_path!(path)
|
|
39
|
+
File.open(path, File::RDONLY | File::NOFOLLOW) do |file|
|
|
40
|
+
stat = file.stat
|
|
41
|
+
unless stat.file? && stat.uid.zero? && (stat.mode & 0o022).zero?
|
|
42
|
+
raise RuntimeUnavailableError, "installed artifact descriptor ownership is unsafe"
|
|
43
|
+
end
|
|
44
|
+
bytes = file.read(limit + 1)
|
|
45
|
+
raise RuntimeUnavailableError, "installed artifact is oversized" if bytes.bytesize > limit
|
|
46
|
+
bytes
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def activation_routes(paths, service_unit)
|
|
51
|
+
routes = []
|
|
52
|
+
visited = 0
|
|
53
|
+
pending = paths.map { |path| [path, 0] }
|
|
54
|
+
until pending.empty?
|
|
55
|
+
directory, depth = pending.shift
|
|
56
|
+
begin
|
|
57
|
+
ProtectedSocket.root_path!(directory, directory: true)
|
|
58
|
+
rescue RuntimeUnavailableError
|
|
59
|
+
if !File.exist?(directory) && !File.symlink?(directory)
|
|
60
|
+
ancestor = File.dirname(directory)
|
|
61
|
+
ancestor = File.dirname(ancestor) until File.exist?(ancestor) || ancestor == "/"
|
|
62
|
+
ProtectedSocket.root_path!(ancestor, directory: true)
|
|
63
|
+
next
|
|
64
|
+
end
|
|
65
|
+
raise
|
|
66
|
+
end
|
|
67
|
+
Dir.children(directory).sort.each do |name|
|
|
68
|
+
visited += 1
|
|
69
|
+
raise RuntimeUnavailableError, "installed activation graph is oversized" if visited > 50_000
|
|
70
|
+
path = File.join(directory, name)
|
|
71
|
+
stat = File.lstat(path)
|
|
72
|
+
if stat.symlink?
|
|
73
|
+
raise RuntimeUnavailableError, "activation graph link owner differs" unless stat.uid.zero?
|
|
74
|
+
target = File.expand_path(File.readlink(path), directory)
|
|
75
|
+
if File.basename(target) == service_unit ||
|
|
76
|
+
(name == service_unit && directory.match?(/\.(?:wants|requires|upholds)\z/))
|
|
77
|
+
routes << path
|
|
78
|
+
end
|
|
79
|
+
elsif stat.directory?
|
|
80
|
+
raise RuntimeUnavailableError, "activation graph directory is too deep" if depth >= 8
|
|
81
|
+
pending << [path, depth + 1]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
routes
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def sha256(path)
|
|
89
|
+
ProtectedSocket.root_path!(path)
|
|
90
|
+
File.open(path, File::RDONLY | File::NOFOLLOW) do |file|
|
|
91
|
+
stat = file.stat
|
|
92
|
+
unless stat.file? && stat.uid.zero? && (stat.mode & 0o6022).zero? && stat.size <= 268_435_456
|
|
93
|
+
raise RuntimeUnavailableError, "installed artifact kind/mode/size is unsafe"
|
|
94
|
+
end
|
|
95
|
+
digest = Digest::SHA256.new
|
|
96
|
+
while (chunk = file.read(65_536))
|
|
97
|
+
digest.update(chunk)
|
|
98
|
+
end
|
|
99
|
+
digest.hexdigest
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def initialize(scope:, native:, bootstrap:, worker_executable:, worker_uid:, worker_gid:, files: Files.new)
|
|
105
|
+
@scope, @native, @bootstrap, @worker_executable, @files = scope, native, bootstrap, worker_executable, files
|
|
106
|
+
@worker_uid, @worker_gid = worker_uid, worker_gid
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def verify!(manager:)
|
|
110
|
+
slot = @scope.fetch("slot_id")
|
|
111
|
+
unless slot.is_a?(String) && SystemdScopeManager::UNIT.match?(slot)
|
|
112
|
+
raise RuntimeUnavailableError, "installed slot identity is invalid"
|
|
113
|
+
end
|
|
114
|
+
bytes = @files.read("/etc/ace/execution-slots/#{slot}/unit-manifest.json", limit: 65_536)
|
|
115
|
+
unless Digest::SHA256.hexdigest(bytes) == @scope.fetch("unit_manifest_sha256")
|
|
116
|
+
raise RuntimeUnavailableError, "unit manifest bytes differ from deployment"
|
|
117
|
+
end
|
|
118
|
+
manifest = JSON.parse(bytes)
|
|
119
|
+
unless manifest.is_a?(Hash) && manifest.keys.sort == %w[artifacts properties schema slot_id] &&
|
|
120
|
+
manifest["schema"] == SCHEMA && manifest["slot_id"] == slot
|
|
121
|
+
raise RuntimeUnavailableError, "unit manifest schema/slot differs"
|
|
122
|
+
end
|
|
123
|
+
artifacts = verify_artifacts!(manifest.fetch("artifacts"))
|
|
124
|
+
profile = manager.inspect_profile
|
|
125
|
+
verify_profile!(profile, manifest.fetch("properties"), artifacts)
|
|
126
|
+
manifest
|
|
127
|
+
rescue SystemCallError, IOError, JSON::ParserError, KeyError, TypeError, NoMethodError
|
|
128
|
+
raise RuntimeUnavailableError, "installed execution units cannot be verified"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def verify_artifacts!(artifacts)
|
|
134
|
+
unless artifacts.is_a?(Array) && artifacts.size.between?(7, 512) &&
|
|
135
|
+
artifacts.all? { |a| a.is_a?(Hash) && a.keys.sort == %w[host_path role sha256 view_path] } &&
|
|
136
|
+
artifacts.map { |a| a["host_path"] }.uniq.size == artifacts.size
|
|
137
|
+
raise RuntimeUnavailableError, "installed unit artifact declarations differ"
|
|
138
|
+
end
|
|
139
|
+
roles = artifacts.group_by { |a| a.fetch("role") }
|
|
140
|
+
unless REQUIRED_ROLES.all? { |role| roles.key?(role) } && (roles.keys - ROLES).empty? &&
|
|
141
|
+
%w[slice_fragment service_fragment native_executable native_configuration readiness_executable bootstrap worker_executable].all? { |role| roles[role].size == 1 }
|
|
142
|
+
raise RuntimeUnavailableError, "installed unit artifacts are incomplete or ambiguous"
|
|
143
|
+
end
|
|
144
|
+
artifacts.each do |artifact|
|
|
145
|
+
path, digest = artifact.values_at("host_path", "sha256")
|
|
146
|
+
unless path?(path) && path?(artifact["view_path"]) && digest.is_a?(String) && digest.match?(/\A[0-9a-f]{64}\z/) && @files.sha256(path) == digest
|
|
147
|
+
raise RuntimeUnavailableError, "installed unit artifact identity changed"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
expected = {"native_executable" => @native.fetch("executable"), "bootstrap" => @bootstrap,
|
|
151
|
+
"worker_executable" => @worker_executable}
|
|
152
|
+
unless expected.all? { |role, path| roles.fetch(role).first.fetch("view_path") == path } &&
|
|
153
|
+
roles.fetch("native_executable").first.fetch("sha256") == @native.fetch("executable_sha256")
|
|
154
|
+
raise RuntimeUnavailableError, "installed executable declarations differ from deployment"
|
|
155
|
+
end
|
|
156
|
+
roles
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def verify_profile!(profile, expected, artifacts)
|
|
160
|
+
unless expected.is_a?(Hash) && expected.keys.sort == %w[service slice]
|
|
161
|
+
raise RuntimeUnavailableError, "unit profile declarations differ"
|
|
162
|
+
end
|
|
163
|
+
{"slice" => SystemdScopeManager::UNIT_GRAPH_SIGNATURES.keys,
|
|
164
|
+
"service" => (SystemdScopeManager::UNIT_GRAPH_SIGNATURES.keys + SystemdScopeManager::SERVICE_EXEC_SIGNATURES.keys).uniq}.each do |kind, keys|
|
|
165
|
+
wanted, actual = expected.fetch(kind), profile.fetch(kind)
|
|
166
|
+
projected = actual.dup
|
|
167
|
+
%w[ExecStartEx ExecStartPostEx].each do |key|
|
|
168
|
+
projected[key] = actual.fetch(key).map { |command| command.first(3) } if kind == "service"
|
|
169
|
+
end
|
|
170
|
+
unless wanted.is_a?(Hash) && wanted.keys.sort == keys.sort && projected == wanted &&
|
|
171
|
+
actual["Id"] == @scope.fetch("#{kind}_unit") && actual["Names"] == [actual["Id"]] &&
|
|
172
|
+
actual["LoadState"] == "loaded" && actual["NeedDaemonReload"] == false
|
|
173
|
+
raise RuntimeUnavailableError, "effective installed unit properties differ"
|
|
174
|
+
end
|
|
175
|
+
fragment = artifacts.fetch("#{kind}_fragment").first.fetch("host_path")
|
|
176
|
+
unless actual["FragmentPath"] == fragment && File.basename(fragment) == @scope.fetch("#{kind}_unit")
|
|
177
|
+
raise RuntimeUnavailableError, "system manager loaded another unit fragment"
|
|
178
|
+
end
|
|
179
|
+
unless actual.fetch("DropInPaths").is_a?(Array) && actual.fetch("DropInPaths").all? { |path|
|
|
180
|
+
artifacts.fetch("unit_dropin", []).any? { |artifact| artifact["host_path"] == path }
|
|
181
|
+
}
|
|
182
|
+
raise RuntimeUnavailableError, "system manager loaded an unverified drop-in"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
slice, service = profile.values_at("slice", "service")
|
|
186
|
+
verify_graph!(slice, service, profile.fetch("ancestors"), profile.fetch("prerequisites"))
|
|
187
|
+
unless @files.activation_routes(profile.fetch("unit_paths"), @scope.fetch("service_unit")).empty?
|
|
188
|
+
raise RuntimeUnavailableError, "service has installed outside enablement or alias routes"
|
|
189
|
+
end
|
|
190
|
+
unless slice["StopWhenUnneeded"] == false && service["Sockets"] == [] &&
|
|
191
|
+
service["RootDirectory"] == @scope.fetch("root_directory") &&
|
|
192
|
+
service["NetworkNamespacePath"] == @scope.fetch("network_namespace_path") &&
|
|
193
|
+
credential_id(service.fetch("User"), :uid) == @worker_uid &&
|
|
194
|
+
credential_id(service.fetch("Group"), :gid) == @worker_gid &&
|
|
195
|
+
SERVICE_REQUIRED.all? { |key, value| service[key] == value } &&
|
|
196
|
+
service["SupplementaryGroups"] == [] &&
|
|
197
|
+
service["RestrictAddressFamilies"] == [true, %w[AF_INET AF_INET6 AF_UNIX]] &&
|
|
198
|
+
EMPTY_EXEC.all? { |key| service[key] == [] }
|
|
199
|
+
raise RuntimeUnavailableError, "execution unit lacks the required retained isolation profile"
|
|
200
|
+
end
|
|
201
|
+
verify_command!(service.fetch("ExecStartEx"), artifacts.fetch("native_executable").first.fetch("view_path"))
|
|
202
|
+
verify_command!(service.fetch("ExecStartPostEx"), artifacts.fetch("readiness_executable").first.fetch("view_path"))
|
|
203
|
+
unless service.fetch("ExecStartEx").first[1] == [@native.fetch("executable"), "server"] &&
|
|
204
|
+
service.fetch("ExecStartPostEx").first[1] == [artifacts.fetch("readiness_executable").first.fetch("view_path"), @scope.fetch("slot_id")]
|
|
205
|
+
raise RuntimeUnavailableError, "unit commands differ from the fixed native/readiness protocol"
|
|
206
|
+
end
|
|
207
|
+
environment = service.fetch("Environment")
|
|
208
|
+
unless environment.is_a?(Array) && environment.all? { |entry| entry.is_a?(String) && !entry.include?("\0") } &&
|
|
209
|
+
environment.all? { |entry| key, value = entry.split("=", 2); NATIVE_ENVIRONMENT.include?(key) && value && !value.empty? } &&
|
|
210
|
+
environment.map { |entry| entry.split("=", 2).first }.uniq.size == environment.size &&
|
|
211
|
+
environment.include?("HERDR_CONFIG_PATH=#{artifacts.fetch('native_configuration').first.fetch('view_path')}")
|
|
212
|
+
raise RuntimeUnavailableError, "native configuration is not the exact immutable input"
|
|
213
|
+
end
|
|
214
|
+
verify_artifact_projection!(service, artifacts)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def verify_graph!(slice, service, ancestors, prerequisites)
|
|
218
|
+
unless ancestors.is_a?(Hash) && !ancestors.empty?
|
|
219
|
+
raise RuntimeUnavailableError, "parent activation graph is incomplete"
|
|
220
|
+
end
|
|
221
|
+
parents = [slice, *ancestors.values]
|
|
222
|
+
parents.each_with_index do |unit, index|
|
|
223
|
+
parent = parents[index + 1]
|
|
224
|
+
permitted = parent ? [parent.fetch("Id")] : []
|
|
225
|
+
unless unit["LoadState"] == "loaded" && unit["NeedDaemonReload"] == false &&
|
|
226
|
+
unit["Requires"] == permitted && unit["Wants"] == [] &&
|
|
227
|
+
(EMPTY_ACTIVATION + %w[RequiresMountsFor WantsMountsFor]).all? { |key| unit[key] == [] }
|
|
228
|
+
raise RuntimeUnavailableError, "parent activation graph could start outside its retained hierarchy"
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
permitted_paths = [@scope.fetch("root_directory"), *service.fetch("RuntimeDirectory").map { |path| "/run/#{path}" }]
|
|
232
|
+
unless prerequisites.is_a?(Hash) && prerequisites.all? { |unit, value|
|
|
233
|
+
value["Id"] == unit && value["LoadState"] == "loaded" && value["ActiveState"] == "active" && value["Job"] == [0, "/"] &&
|
|
234
|
+
(unit == "systemd-journald.socket" && value["SubState"] == "listening" ||
|
|
235
|
+
unit.end_with?(".mount") && value["SubState"] == "mounted" && (value["Where"] == "/" || path?(value["Where"])) &&
|
|
236
|
+
permitted_paths.any? { |path| covers?(value["Where"], path) })
|
|
237
|
+
} && (service.values_at("Requires", "Wants", "After").flatten.uniq - [slice["Id"]]).sort == prerequisites.keys.sort &&
|
|
238
|
+
service.values_at("Requires", "Wants").flatten.none? { |unit| unit == "systemd-journald.socket" }
|
|
239
|
+
raise RuntimeUnavailableError, "implicit backing mounts/logging are not active exact prerequisites"
|
|
240
|
+
end
|
|
241
|
+
unless parents.last["Id"] == "-.slice" && service["Slice"] == slice["Id"] &&
|
|
242
|
+
service["Requires"].include?(slice["Id"]) && service["After"].include?(slice["Id"]) &&
|
|
243
|
+
service["WantsMountsFor"] == [@scope.fetch("root_directory")] &&
|
|
244
|
+
service.fetch("RuntimeDirectory") == [@scope.fetch("runtime_directory").delete_prefix("/run/")] &&
|
|
245
|
+
service["RequiresMountsFor"].sort == service.fetch("RuntimeDirectory").map { |path| "/run/#{path}" }.sort &&
|
|
246
|
+
service["WorkingDirectory"] == "" && service["DefaultDependencies"] == false &&
|
|
247
|
+
%w[RequiredBy RequisiteOf WantedBy BoundBy UpheldBy ConsistsOf].all? { |key| service[key] == [] } &&
|
|
248
|
+
%w[disabled static].include?(service["UnitFileState"]) &&
|
|
249
|
+
EMPTY_ACTIVATION.all? { |key| service[key] == [] }
|
|
250
|
+
raise RuntimeUnavailableError, "service has outside activation or parent stop propagation"
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def verify_artifact_projection!(service, artifacts)
|
|
255
|
+
writable = service.fetch("BindPaths")
|
|
256
|
+
readonly = service.fetch("BindReadOnlyPaths")
|
|
257
|
+
mounts = writable + readonly
|
|
258
|
+
unless mounts.all? { |mount| path?(mount[0]) && path?(mount[1]) && mount[2] == false && mount[3] == 0 } &&
|
|
259
|
+
mounts.map { |mount| mount[1] }.uniq.size == mounts.size
|
|
260
|
+
raise RuntimeUnavailableError, "unit mount projection is ambiguous or optional"
|
|
261
|
+
end
|
|
262
|
+
artifacts.each do |role, entries|
|
|
263
|
+
next if %w[slice_fragment service_fragment unit_dropin].include?(role)
|
|
264
|
+
entries.each do |artifact|
|
|
265
|
+
view = artifact.fetch("view_path")
|
|
266
|
+
if IMPLICIT_NAMESPACE_ROOTS.any? { |root| covers?(root, view) } ||
|
|
267
|
+
service.fetch("ReadWritePaths").any? { |root| !path?(root) || covers?(root, view) }
|
|
268
|
+
raise RuntimeUnavailableError, "immutable artifact is inside an implicit or writable namespace projection"
|
|
269
|
+
end
|
|
270
|
+
if writable.any? { |mount| overlaps?(view, mount[1]) }
|
|
271
|
+
raise RuntimeUnavailableError, "immutable unit artifact has a writable overlay"
|
|
272
|
+
end
|
|
273
|
+
selected = readonly.select { |mount| covers?(mount[1], view) }.max_by { |mount| mount[1].length }
|
|
274
|
+
relative = selected && view.delete_prefix(selected[1]).delete_prefix("/")
|
|
275
|
+
host = selected ? (relative.empty? ? selected[0] : File.join(selected[0], relative)) : @scope.fetch("root_directory") + view
|
|
276
|
+
unless artifact.fetch("host_path") == host
|
|
277
|
+
raise RuntimeUnavailableError, "hashed artifact is shadowed by another effective unit mount"
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def covers?(root, path) = root == path || root == "/" || path.start_with?(root + "/")
|
|
284
|
+
def overlaps?(left, right) = covers?(left, right) || covers?(right, left)
|
|
285
|
+
|
|
286
|
+
def verify_command!(value, executable)
|
|
287
|
+
unless value.is_a?(Array) && value.size == 1 && value.first.is_a?(Array) && value.first.size == 10 &&
|
|
288
|
+
value.first[0] == executable && value.first[1].is_a?(Array) && value.first[1].first == executable &&
|
|
289
|
+
value.first[1].all? { |arg| arg.is_a?(String) && !arg.include?("\0") } && value.first[2] == [] &&
|
|
290
|
+
value.first[3..].all? { |number| number.is_a?(Integer) }
|
|
291
|
+
raise RuntimeUnavailableError, "unit command bypasses fixed unprivileged execution"
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def credential_id(value, kind)
|
|
296
|
+
return Integer(value, 10) if value.match?(/\A[1-9][0-9]*\z/)
|
|
297
|
+
kind == :uid ? Etc.getpwnam(value).uid : Etc.getgrnam(value).gid
|
|
298
|
+
rescue ArgumentError
|
|
299
|
+
raise RuntimeUnavailableError, "installed unit principal is unavailable"
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def path?(value)
|
|
303
|
+
value.is_a?(String) && value.bytesize.between?(2, 4096) && value.start_with?("/") &&
|
|
304
|
+
!value.match?(/[\s\0;]/) && File.expand_path(value) == value
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|