@homeflare/alchemy 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +74 -41
  2. package/dist/caddy/admin-calls.d.ts +48 -0
  3. package/dist/caddy/admin-calls.d.ts.map +1 -0
  4. package/dist/caddy/admin-guard.d.ts +40 -0
  5. package/dist/caddy/admin-guard.d.ts.map +1 -0
  6. package/dist/caddy/admin.d.ts +73 -0
  7. package/dist/caddy/admin.d.ts.map +1 -0
  8. package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
  9. package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
  10. package/dist/caddy/config-form.d.ts +27 -0
  11. package/dist/caddy/config-form.d.ts.map +1 -0
  12. package/dist/caddy/config-lifecycle.d.ts +91 -0
  13. package/dist/caddy/config-lifecycle.d.ts.map +1 -0
  14. package/dist/caddy/config.d.ts +34 -0
  15. package/dist/caddy/config.d.ts.map +1 -0
  16. package/dist/caddy/digest.d.ts +7 -0
  17. package/dist/caddy/digest.d.ts.map +1 -0
  18. package/dist/caddy/fake-caddy.d.ts +54 -0
  19. package/dist/caddy/fake-caddy.d.ts.map +1 -0
  20. package/dist/caddy/index.d.ts +22 -0
  21. package/dist/caddy/index.d.ts.map +1 -0
  22. package/dist/caddy/index.js +970 -0
  23. package/dist/caddy/index.js.map +28 -0
  24. package/dist/caddy/local-admin.d.ts +45 -0
  25. package/dist/caddy/local-admin.d.ts.map +1 -0
  26. package/dist/caddy/providers.d.ts +14 -0
  27. package/dist/caddy/providers.d.ts.map +1 -0
  28. package/dist/caddy/with-file.d.ts +51 -0
  29. package/dist/caddy/with-file.d.ts.map +1 -0
  30. package/dist/cloudflare/fake-mesh.d.ts +58 -0
  31. package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
  32. package/dist/cloudflare/index.d.ts +9 -3
  33. package/dist/cloudflare/index.d.ts.map +1 -1
  34. package/dist/cloudflare/index.js +218 -7
  35. package/dist/cloudflare/index.js.map +9 -4
  36. package/dist/cloudflare/mesh-node-api.d.ts +75 -0
  37. package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
  38. package/dist/cloudflare/mesh-node-form.d.ts +82 -0
  39. package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
  40. package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
  41. package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
  42. package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
  43. package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
  44. package/dist/cloudflare/mesh-node-token.d.ts +23 -0
  45. package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
  46. package/dist/cloudflare/mesh-node.d.ts +48 -0
  47. package/dist/cloudflare/mesh-node.d.ts.map +1 -0
  48. package/dist/cloudflare/providers.d.ts +1 -12
  49. package/dist/cloudflare/providers.d.ts.map +1 -1
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/launchd/fake-sudo.d.ts +50 -0
  53. package/dist/launchd/fake-sudo.d.ts.map +1 -0
  54. package/dist/launchd/host-effect.d.ts +18 -9
  55. package/dist/launchd/host-effect.d.ts.map +1 -1
  56. package/dist/launchd/host-file-form.d.ts +5 -0
  57. package/dist/launchd/host-file-form.d.ts.map +1 -1
  58. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  59. package/dist/launchd/index.d.ts +2 -0
  60. package/dist/launchd/index.d.ts.map +1 -1
  61. package/dist/launchd/index.js +342 -8
  62. package/dist/launchd/index.js.map +16 -12
  63. package/dist/launchd/job-preflight.d.ts.map +1 -1
  64. package/dist/launchd/job-validate.d.ts +2 -0
  65. package/dist/launchd/job-validate.d.ts.map +1 -1
  66. package/dist/launchd/local-runner.d.ts +2 -0
  67. package/dist/launchd/local-runner.d.ts.map +1 -1
  68. package/dist/launchd/providers.d.ts +2 -1
  69. package/dist/launchd/providers.d.ts.map +1 -1
  70. package/dist/launchd/runner.d.ts +7 -5
  71. package/dist/launchd/runner.d.ts.map +1 -1
  72. package/dist/launchd/secret-tripwire.d.ts +1 -0
  73. package/dist/launchd/secret-tripwire.d.ts.map +1 -1
  74. package/dist/launchd/sudo-allowlist.d.ts +47 -0
  75. package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
  76. package/dist/launchd/sudo-guard.d.ts +56 -0
  77. package/dist/launchd/sudo-guard.d.ts.map +1 -0
  78. package/dist/launchd/sudo-runner.d.ts +60 -0
  79. package/dist/launchd/sudo-runner.d.ts.map +1 -0
  80. package/dist/launchd/sudo-stage.d.ts +7 -0
  81. package/dist/launchd/sudo-stage.d.ts.map +1 -0
  82. package/dist/openbao/auth-role-form.d.ts +0 -9
  83. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  84. package/dist/openbao/auth-role.d.ts +3 -2
  85. package/dist/openbao/auth-role.d.ts.map +1 -1
  86. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  87. package/dist/openbao/fake-engines-roles.d.ts +32 -0
  88. package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
  89. package/dist/openbao/fake-engines.d.ts +36 -0
  90. package/dist/openbao/fake-engines.d.ts.map +1 -0
  91. package/dist/openbao/fake-families.d.ts +49 -0
  92. package/dist/openbao/fake-families.d.ts.map +1 -0
  93. package/dist/openbao/fake-stack.d.ts +29 -0
  94. package/dist/openbao/fake-stack.d.ts.map +1 -0
  95. package/dist/openbao/host-approles.d.ts.map +1 -1
  96. package/dist/openbao/index.js +393 -234
  97. package/dist/openbao/index.js.map +23 -19
  98. package/dist/openbao/jwt-config.d.ts +5 -0
  99. package/dist/openbao/jwt-config.d.ts.map +1 -1
  100. package/dist/openbao/jwt-role.d.ts +2 -1
  101. package/dist/openbao/jwt-role.d.ts.map +1 -1
  102. package/dist/openbao/kubernetes-role.d.ts +2 -1
  103. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  104. package/dist/openbao/mfa-enforcement.d.ts +6 -3
  105. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  106. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  107. package/dist/openbao/pki-role.d.ts.map +1 -1
  108. package/dist/openbao/plugin.d.ts.map +1 -1
  109. package/dist/openbao/policy.d.ts +2 -0
  110. package/dist/openbao/policy.d.ts.map +1 -1
  111. package/dist/openbao/proxmox-role-form.d.ts +18 -8
  112. package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
  113. package/dist/openbao/proxmox-role-wire.d.ts +18 -0
  114. package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
  115. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  116. package/dist/openbao/rename-identity.d.ts +69 -0
  117. package/dist/openbao/rename-identity.d.ts.map +1 -0
  118. package/dist/openbao/rename.d.ts +107 -0
  119. package/dist/openbao/rename.d.ts.map +1 -0
  120. package/dist/openbao/ssh-role-erase.d.ts +11 -0
  121. package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
  122. package/dist/openbao/ssh-role.d.ts.map +1 -1
  123. package/docs/caddy-admin.md +24 -0
  124. package/docs/caddy.md +199 -0
  125. package/docs/launchd-sudo.md +122 -0
  126. package/docs/launchd.md +7 -4
  127. package/docs/mesh-node.md +188 -0
  128. package/docs/peers.md +39 -0
  129. package/package.json +7 -2
  130. package/src/caddy/admin-calls.ts +128 -0
  131. package/src/caddy/admin-guard.ts +170 -0
  132. package/src/caddy/admin.ts +74 -0
  133. package/src/caddy/adopt-scope.test.ts +82 -0
  134. package/src/caddy/caddyfile-tripwire.ts +133 -0
  135. package/src/caddy/config-adopt.test.ts +235 -0
  136. package/src/caddy/config-form.ts +64 -0
  137. package/src/caddy/config-lifecycle.test.ts +196 -0
  138. package/src/caddy/config-lifecycle.ts +239 -0
  139. package/src/caddy/config.ts +153 -0
  140. package/src/caddy/digest.ts +44 -0
  141. package/src/caddy/fake-caddy.ts +179 -0
  142. package/src/caddy/guards.test.ts +156 -0
  143. package/src/caddy/index.ts +26 -0
  144. package/src/caddy/local-admin.test.ts +160 -0
  145. package/src/caddy/local-admin.ts +189 -0
  146. package/src/caddy/providers.test.ts +229 -0
  147. package/src/caddy/providers.ts +17 -0
  148. package/src/caddy/with-file.ts +66 -0
  149. package/src/cloudflare/fake-mesh.ts +181 -0
  150. package/src/cloudflare/index.ts +9 -3
  151. package/src/cloudflare/mesh-node-api.ts +127 -0
  152. package/src/cloudflare/mesh-node-edges.test.ts +103 -0
  153. package/src/cloudflare/mesh-node-form.test.ts +80 -0
  154. package/src/cloudflare/mesh-node-form.ts +115 -0
  155. package/src/cloudflare/mesh-node-harness.ts +124 -0
  156. package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
  157. package/src/cloudflare/mesh-node-policy.test.ts +151 -0
  158. package/src/cloudflare/mesh-node-token.test.ts +117 -0
  159. package/src/cloudflare/mesh-node-token.ts +125 -0
  160. package/src/cloudflare/mesh-node.test.ts +172 -0
  161. package/src/cloudflare/mesh-node.ts +120 -0
  162. package/src/cloudflare/providers.ts +20 -2
  163. package/src/index.ts +1 -0
  164. package/src/launchd/fake-sudo.ts +136 -0
  165. package/src/launchd/host-effect.ts +33 -1
  166. package/src/launchd/host-file-form.ts +10 -2
  167. package/src/launchd/host-file-lifecycle.ts +2 -1
  168. package/src/launchd/index.ts +2 -0
  169. package/src/launchd/job-preflight.ts +3 -2
  170. package/src/launchd/job-validate.ts +1 -1
  171. package/src/launchd/local-runner.ts +6 -3
  172. package/src/launchd/providers.test.ts +3 -0
  173. package/src/launchd/providers.ts +2 -1
  174. package/src/launchd/runner.ts +7 -5
  175. package/src/launchd/secret-tripwire.ts +1 -1
  176. package/src/launchd/sudo-allowlist.test.ts +191 -0
  177. package/src/launchd/sudo-allowlist.ts +213 -0
  178. package/src/launchd/sudo-guard.test.ts +100 -0
  179. package/src/launchd/sudo-guard.ts +151 -0
  180. package/src/launchd/sudo-lifecycle.test.ts +172 -0
  181. package/src/launchd/sudo-runner-files.test.ts +240 -0
  182. package/src/launchd/sudo-runner.test.ts +126 -0
  183. package/src/launchd/sudo-runner.ts +235 -0
  184. package/src/launchd/sudo-stage.ts +28 -0
  185. package/src/openbao/README.md +8 -2
  186. package/src/openbao/REPLACE.md +126 -22
  187. package/src/openbao/auth-role-form.test.ts +2 -16
  188. package/src/openbao/auth-role-form.ts +0 -11
  189. package/src/openbao/auth-role.ts +35 -14
  190. package/src/openbao/cloudflare-role-wire.test.ts +8 -34
  191. package/src/openbao/cloudflare-role.ts +22 -8
  192. package/src/openbao/fake-engines-roles.ts +114 -0
  193. package/src/openbao/fake-engines.ts +126 -0
  194. package/src/openbao/fake-families.ts +182 -0
  195. package/src/openbao/fake-stack.ts +112 -0
  196. package/src/openbao/host-approles.test.ts +15 -2
  197. package/src/openbao/host-approles.ts +19 -4
  198. package/src/openbao/jwt-config.ts +26 -5
  199. package/src/openbao/jwt-role.ts +24 -8
  200. package/src/openbao/kubernetes-role.ts +20 -8
  201. package/src/openbao/mfa-enforcement.ts +23 -8
  202. package/src/openbao/mfa-totp.ts +21 -8
  203. package/src/openbao/pki-role.ts +19 -7
  204. package/src/openbao/plugin.ts +41 -15
  205. package/src/openbao/policy-rename.test.ts +112 -0
  206. package/src/openbao/policy-wire.test.ts +7 -26
  207. package/src/openbao/policy.ts +27 -4
  208. package/src/openbao/proxmox-role-form.ts +27 -8
  209. package/src/openbao/proxmox-role-wire.ts +24 -0
  210. package/src/openbao/proxmox-role.ts +52 -36
  211. package/src/openbao/rename-adoption.test.ts +82 -0
  212. package/src/openbao/rename-families.test.ts +122 -0
  213. package/src/openbao/rename-identity.test.ts +129 -0
  214. package/src/openbao/rename-identity.ts +117 -0
  215. package/src/openbao/rename-occupied.test.ts +166 -0
  216. package/src/openbao/rename.ts +166 -0
  217. package/src/openbao/role-rename.test.ts +205 -0
  218. package/src/openbao/ssh-role-erase.ts +50 -0
  219. package/src/openbao/ssh-role.ts +16 -54
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The allowlist, as pure argv checks. Each refusal here is a shape that would do real damage as
3
+ * root; each acceptance is a shape the providers or the runner actually produce.
4
+ */
5
+ import { describe, expect, test } from 'bun:test';
6
+ import { LAUNCHCTL } from './launchctl.ts';
7
+ import {
8
+ INSTALL,
9
+ RM,
10
+ checkPrefixes,
11
+ octalMode,
12
+ prefixOf,
13
+ privilegedProblem,
14
+ routeExec,
15
+ } from './sudo-allowlist.ts';
16
+
17
+ const DAEMONS = '/Library/LaunchDaemons';
18
+ const context = { prefixes: [DAEMONS, '/opt/example'], staged: '/tmp/hf-sudo-x/staged' };
19
+ const allowed = (argv: string[]) => privilegedProblem(argv, context);
20
+ const PLIST = `${DAEMONS}/com.example.a.plist`;
21
+
22
+ describe('checkPrefixes', () => {
23
+ test.each([
24
+ [[], 'no prefixes'],
25
+ [['/'], 'every path'],
26
+ [['opt/example'], 'absolute'],
27
+ [['/opt/example/'], 'normalised'],
28
+ [['/opt/../etc'], 'normalised'],
29
+ ])('refuses %j', (prefixes, message) => {
30
+ expect(() => checkPrefixes(prefixes)).toThrow(message);
31
+ });
32
+
33
+ test('keeps a valid list', () => {
34
+ expect(checkPrefixes([DAEMONS])).toEqual([DAEMONS]);
35
+ });
36
+ });
37
+
38
+ describe('prefixOf', () => {
39
+ test('strictly under, never a sibling with the same leading text, never the prefix itself', () => {
40
+ expect(prefixOf('/opt/example/a.conf', context.prefixes)).toBe('/opt/example');
41
+ expect(prefixOf('/opt/example-other/a.conf', context.prefixes)).toBeUndefined();
42
+ expect(prefixOf('/opt/example', context.prefixes)).toBeUndefined();
43
+ });
44
+
45
+ test('a path that walks out with .. is not under anything', () => {
46
+ expect(prefixOf('/opt/example/../../etc/sudoers', context.prefixes)).toBeUndefined();
47
+ });
48
+ });
49
+
50
+ describe('launchctl shapes', () => {
51
+ test.each([
52
+ [[LAUNCHCTL, 'bootstrap', 'system', PLIST]],
53
+ [[LAUNCHCTL, 'bootout', 'system/com.example.a']],
54
+ [[LAUNCHCTL, 'kickstart', 'system/com.example.a']],
55
+ [[LAUNCHCTL, 'kickstart', '-k', '-p', 'system/com.example.a']],
56
+ ])('allows %j', (argv) => {
57
+ expect(allowed(argv)).toBeUndefined();
58
+ });
59
+
60
+ test.each([
61
+ // ⛔ launchctl(1): a bare domain removes the whole system domain.
62
+ ['bootout of the bare system domain', [LAUNCHCTL, 'bootout', 'system']],
63
+ ['bootout by domain plus path', [LAUNCHCTL, 'bootout', 'system', PLIST]],
64
+ // ⛔ launchctl(1): a directory bootstraps every plist in it.
65
+ ['bootstrap of a directory', [LAUNCHCTL, 'bootstrap', 'system', DAEMONS]],
66
+ ['bootstrap of two plists', [LAUNCHCTL, 'bootstrap', 'system', PLIST, PLIST]],
67
+ [
68
+ 'bootstrap outside every prefix',
69
+ [LAUNCHCTL, 'bootstrap', 'system', '/tmp/com.example.a.plist'],
70
+ ],
71
+ ['bootstrap of a non-plist', [LAUNCHCTL, 'bootstrap', 'system', `${DAEMONS}/com.example.a`]],
72
+ // ★ Only where launchd loads daemons at boot: anywhere else is a job that vanishes on restart.
73
+ [
74
+ 'bootstrap from another prefix',
75
+ [LAUNCHCTL, 'bootstrap', 'system', '/opt/example/com.example.a.plist'],
76
+ ],
77
+ [
78
+ 'bootstrap from a subdirectory',
79
+ [LAUNCHCTL, 'bootstrap', 'system', `${DAEMONS}/sub/com.example.a.plist`],
80
+ ],
81
+ ['bootstrap into a gui domain', [LAUNCHCTL, 'bootstrap', 'gui/501', PLIST]],
82
+ ['a reserved label', [LAUNCHCTL, 'bootout', 'system/com.apple.xpc.foo']],
83
+ ['a label that is a path', [LAUNCHCTL, 'bootout', 'system/../x']],
84
+ [
85
+ 'kickstart -s (suspended, for a debugger)',
86
+ [LAUNCHCTL, 'kickstart', '-s', 'system/com.example.a'],
87
+ ],
88
+ ['a repeated flag', [LAUNCHCTL, 'kickstart', '-k', '-k', 'system/com.example.a']],
89
+ ['kickstart of the bare domain', [LAUNCHCTL, 'kickstart', '-k', 'system']],
90
+ ['kickstart of a reserved label', [LAUNCHCTL, 'kickstart', 'system/com.apple.xpc.foo']],
91
+ ['enable', [LAUNCHCTL, 'enable', 'system/com.example.a']],
92
+ ['print, which never needs root', [LAUNCHCTL, 'print', 'system/com.example.a']],
93
+ ])('refuses %s', (_name, argv) => {
94
+ expect(allowed(argv)).toBeString();
95
+ });
96
+ });
97
+
98
+ test('without /Library/LaunchDaemons declared, bootstrap and bootout are refused; kickstart is not', () => {
99
+ const narrow = { prefixes: ['/opt/example'] };
100
+ // ⚠️ bootout first, then a refused rm, would leave a plist launchd loads again at boot.
101
+ expect(privilegedProblem([LAUNCHCTL, 'bootout', 'system/com.example.a'], narrow)).toContain(
102
+ '/Library/LaunchDaemons',
103
+ );
104
+ expect(privilegedProblem([LAUNCHCTL, 'bootstrap', 'system', PLIST], narrow)).toContain(
105
+ '/Library/LaunchDaemons',
106
+ );
107
+ expect(
108
+ privilegedProblem([LAUNCHCTL, 'kickstart', 'system/com.example.a'], narrow),
109
+ ).toBeUndefined();
110
+ });
111
+
112
+ describe('install and rm shapes', () => {
113
+ const staged = context.staged;
114
+ test.each([
115
+ [[INSTALL, '-S', '-m', '0644', staged, '/opt/example/a.conf']],
116
+ [[INSTALL, '-S', '-m', '4755', '-o', '0', staged, '/opt/example/a']],
117
+ [[INSTALL, '-S', '-m', '0644', '-g', '80', staged, '/opt/example/a']],
118
+ [[INSTALL, '-S', '-m', '0644', '-o', '0', '-g', '0', staged, PLIST]],
119
+ [[RM, '-f', '--', '/opt/example/a.conf']],
120
+ ])('allows %j', (argv) => {
121
+ expect(allowed(argv)).toBeUndefined();
122
+ });
123
+
124
+ test.each([
125
+ // ⛔ Copying any other source would publish a root-only file.
126
+ [
127
+ 'a source it did not stage',
128
+ [INSTALL, '-S', '-m', '0644', '/etc/master.passwd', '/opt/example/a'],
129
+ ],
130
+ ['a destination outside every prefix', [INSTALL, '-S', '-m', '0644', staged, '/etc/sudoers']],
131
+ ['a destination that walks out', [INSTALL, '-S', '-m', '0644', staged, '/opt/example/../x']],
132
+ ['no -S', [INSTALL, '-m', '0644', staged, '/opt/example/a']],
133
+ ['a three-digit mode', [INSTALL, '-S', '-m', '644', staged, '/opt/example/a']],
134
+ ['a symbolic mode', [INSTALL, '-S', '-m', 'u+s', staged, '/opt/example/a']],
135
+ ['an owner by name', [INSTALL, '-S', '-m', '0644', '-o', 'root', staged, '/opt/example/a']],
136
+ ['-g before -o', [INSTALL, '-S', '-m', '0644', '-g', '0', '-o', '0', staged, '/opt/example/a']],
137
+ ['-d (make directories)', [INSTALL, '-S', '-m', '0644', '-d', staged, '/opt/example/a']],
138
+ ['two destinations', [INSTALL, '-S', '-m', '0644', staged, '/opt/example/a', '/opt/example/b']],
139
+ ['rm -r', [RM, '-rf', '--', '/opt/example/a']],
140
+ ['rm without --', [RM, '-f', '/opt/example/a']],
141
+ ['rm of two paths', [RM, '-f', '--', '/opt/example/a', '/opt/example/b']],
142
+ ['rm of a prefix itself', [RM, '-f', '--', '/opt/example']],
143
+ ['any other program', ['/bin/sh', '-c', 'true']],
144
+ ])('refuses %s', (_name, argv) => {
145
+ expect(allowed(argv)).toBeString();
146
+ });
147
+
148
+ test('without a staged file, no install is allowed at all', () => {
149
+ const argv = [INSTALL, '-S', '-m', '0644', 'undefined', '/opt/example/a'];
150
+ expect(privilegedProblem(argv, { prefixes: context.prefixes })).toBeString();
151
+ });
152
+
153
+ test('octalMode is four digits, and refuses what chmod cannot mean', () => {
154
+ expect(octalMode(0o644)).toBe('0644');
155
+ expect(octalMode(0o4755)).toBe('4755');
156
+ expect(octalMode(0o10000)).toBeUndefined();
157
+ expect(octalMode(-1)).toBeUndefined();
158
+ expect(octalMode(1.5)).toBeUndefined();
159
+ });
160
+ });
161
+
162
+ describe('routeExec', () => {
163
+ const route = (argv: string[]) => routeExec(argv, 501);
164
+ test('system-domain writes go to root', () => {
165
+ expect(route([LAUNCHCTL, 'bootstrap', 'system', PLIST])).toEqual({ as: 'root' });
166
+ expect(route([LAUNCHCTL, 'bootout', 'system/com.example.a'])).toEqual({ as: 'root' });
167
+ expect(route([LAUNCHCTL, 'kickstart', '-k', 'system/com.example.a'])).toEqual({ as: 'root' });
168
+ // A malformed system write still routes to root, where the allowlist refuses it.
169
+ expect(route([LAUNCHCTL, 'bootout', 'system'])).toEqual({ as: 'root' });
170
+ });
171
+
172
+ test('reads, lookups and the operator’s own gui domain stay unelevated', () => {
173
+ expect(route([LAUNCHCTL, 'print', 'system/com.example.a'])).toEqual({ as: 'operator' });
174
+ expect(route([LAUNCHCTL, 'print-disabled', 'system'])).toEqual({ as: 'operator' });
175
+ expect(route([LAUNCHCTL, 'bootstrap', 'gui/501', '/Users/x/a.plist'])).toEqual({
176
+ as: 'operator',
177
+ });
178
+ expect(route([LAUNCHCTL, 'bootout', 'gui/501/com.example.a'])).toEqual({ as: 'operator' });
179
+ expect(route(['/usr/bin/dscacheutil', '-q', 'user'])).toEqual({ as: 'operator' });
180
+ });
181
+
182
+ test('another user’s domain is refused, not passed through to fail on EPERM', () => {
183
+ expect(route([LAUNCHCTL, 'bootout', 'gui/502/com.example.a'])).toHaveProperty('refuse');
184
+ expect(route([LAUNCHCTL, 'kickstart', 'user/502/com.example.a'])).toHaveProperty('refuse');
185
+ });
186
+
187
+ test('a sudo argv is never passed through', () => {
188
+ expect(route(['/usr/bin/sudo', '-n', '/bin/rm', '-rf', '/'])).toHaveProperty('refuse');
189
+ expect(route(['sudo', 'true'])).toHaveProperty('refuse');
190
+ });
191
+ });
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Everything sudo-runner.ts may run as root, as exact argv shapes, plus the rule for which calls
3
+ * need root at all. Pure: no I/O, so every shape is tested without sudo (sudo-allowlist.test.ts).
4
+ *
5
+ * ⛔ AN ALLOWLIST OF SHAPES, NOT OF PROGRAMS. Allowing "launchctl" would allow `bootout system` with
6
+ * no service, which removes the whole system domain, and `bootstrap system <directory>`, which
7
+ * loads every plist in that directory (launchctl(1), read on macOS 27.2 on 2026-09-21). Each shape
8
+ * below fixes the subcommand, the domain, the flags and the number of operands, so neither of
9
+ * those can be written.
10
+ * ⛔ PATHS ARE CHECKED LEXICALLY HERE: absolute, normalised (the same rule a HostFile path obeys,
11
+ * host-file-form.ts) and STRICTLY under a prefix the stack declared. sudo-guard.ts then checks
12
+ * the host with lstat: the prefix a root-only directory, no symlinks, and only regular files.
13
+ * ⛔ `install` COPIES ONLY THE FILE THIS RUNNER STAGED. Any other source would let the runner copy
14
+ * a file only root can read (/etc/master.passwd, say) to a path the deploying user can read.
15
+ */
16
+ import { pathProblems } from './host-file-form.ts';
17
+ import { plistPathFor } from './job-form.ts';
18
+ import { LABEL, RESERVED_LABEL_PREFIXES } from './job-validate.ts';
19
+ import { LAUNCHCTL } from './launchctl.ts';
20
+
21
+ /**
22
+ * A privileged call that was refused before it ran: by the allowlist, by a host check
23
+ * (sudo-guard.ts), or by sudo itself. ★ Here, beside the allowlist, so the guard and the runner
24
+ * throw the same class and a caller can tell "nothing ran as root" from "it ran and failed".
25
+ */
26
+ export class SudoRefusedError extends Error {
27
+ constructor(message: string) {
28
+ super(message);
29
+ this.name = 'SudoRefusedError';
30
+ }
31
+ }
32
+
33
+ /** ★ Absolute program paths, so neither sudo nor the command is looked up on the caller's PATH. */
34
+ export const SUDO = '/usr/bin/sudo';
35
+ export const INSTALL = '/usr/bin/install';
36
+ export const RM = '/bin/rm';
37
+
38
+ /** The launchctl subcommands that change a domain. Everything else launchctl does is a read. */
39
+ const WRITES: ReadonlySet<string> = new Set(['bootstrap', 'bootout', 'kickstart']);
40
+
41
+ /** A mode as `install -m` takes it: four octal digits, so `0644` and `4755` are both exact. */
42
+ export const octalMode = (mode: number): string | undefined =>
43
+ Number.isSafeInteger(mode) && mode >= 0 && mode <= 0o7777
44
+ ? mode.toString(8).padStart(4, '0')
45
+ : undefined;
46
+
47
+ /** Validate the stack's prefixes once, when the runner is built. */
48
+ export const checkPrefixes = (prefixes: readonly string[]): readonly string[] => {
49
+ if (prefixes.length === 0) {
50
+ throw new Error(
51
+ 'sudoRunner: no prefixes. Name the directories root may write, e.g. /Library/LaunchDaemons.',
52
+ );
53
+ }
54
+ for (const prefix of prefixes) {
55
+ const found = prefix === '/' ? ['"/" would allow every path'] : pathProblems(prefix);
56
+ if (found.length > 0) {
57
+ throw new Error(`sudoRunner: prefix ${JSON.stringify(prefix)}: ${found.join('; ')}`);
58
+ }
59
+ }
60
+ return [...prefixes];
61
+ };
62
+
63
+ /**
64
+ * The declared prefix `path` is strictly under, or `undefined`.
65
+ * ⚠️ `${prefix}/`, never a bare startsWith: `/opt/example` must not admit `/opt/example-other`.
66
+ */
67
+ export const prefixOf = (path: string, prefixes: readonly string[]): string | undefined =>
68
+ pathProblems(path).length > 0
69
+ ? undefined
70
+ : prefixes.find((prefix) => path.startsWith(`${prefix}/`));
71
+
72
+ const labelOk = (label: string): boolean =>
73
+ LABEL.test(label) && !RESERVED_LABEL_PREFIXES.some((prefix) => label.startsWith(prefix));
74
+
75
+ /** The label of `system/<label>`, when it is one LaunchdJob itself would accept. */
76
+ const systemLabel = (target: string | undefined): string | undefined => {
77
+ const label = target?.startsWith('system/') === true ? target.slice('system/'.length) : undefined;
78
+ return label !== undefined && labelOk(label) ? label : undefined;
79
+ };
80
+
81
+ /**
82
+ * A label's daemon plist, when that path is under a declared prefix.
83
+ * ★ ONLY `/Library/LaunchDaemons/<label>.plist`, derived by the same plistPathFor LaunchdJob uses:
84
+ * it is the one directory launchd loads daemons from at boot, so a plist anywhere else under a
85
+ * prefix would be a root daemon that silently vanishes at the next restart. Root never loads one.
86
+ */
87
+ const daemonPlist = (label: string, prefixes: readonly string[]): string | undefined => {
88
+ const path = plistPathFor(label, { kind: 'system' }, undefined);
89
+ return prefixOf(path, prefixes) === undefined ? undefined : path;
90
+ };
91
+
92
+ const NO_DAEMON_PREFIX =
93
+ 'needs /Library/LaunchDaemons among the prefixes, where the job’s plist is written and removed';
94
+
95
+ const launchctlProblem = (
96
+ args: readonly string[],
97
+ prefixes: readonly string[],
98
+ ): string | undefined => {
99
+ const [sub, ...rest] = args;
100
+ if (sub === 'bootstrap') {
101
+ const given = rest[1] ?? '';
102
+ const name = given.slice(given.lastIndexOf('/') + 1);
103
+ const label = name.endsWith('.plist') ? name.slice(0, -'.plist'.length) : '';
104
+ const shaped = rest.length === 2 && rest[0] === 'system' && labelOk(label);
105
+ if (!shaped) return 'bootstrap takes exactly `system /Library/LaunchDaemons/<label>.plist`';
106
+ const plist = daemonPlist(label, prefixes);
107
+ if (plist === undefined) return `bootstrap ${NO_DAEMON_PREFIX}`;
108
+ return given === plist ? undefined : `bootstrap loads only ${plist}, never another path`;
109
+ }
110
+ if (sub === 'bootout') {
111
+ const label = rest.length === 1 ? systemLabel(rest[0]) : undefined;
112
+ if (label === undefined) return 'bootout takes exactly `system/<label>`, never a bare domain';
113
+ /**
114
+ * ⚠️ WITHOUT THE PREFIX, A DELETE STOPS HALFWAY. deleteJob boots the job out, then removes its
115
+ * plist; with /Library/LaunchDaemons undeclared the removal is refused AFTER the bootout, the
116
+ * plist stays, and launchd loads the job again at the next boot. Refused here, nothing moves.
117
+ */
118
+ return daemonPlist(label, prefixes) === undefined ? `bootout ${NO_DAEMON_PREFIX}` : undefined;
119
+ }
120
+ if (sub === 'kickstart') {
121
+ const flags = rest.slice(0, -1);
122
+ const flagsOk =
123
+ flags.every((flag) => flag === '-k' || flag === '-p') && new Set(flags).size === flags.length;
124
+ return flagsOk && systemLabel(rest[rest.length - 1]) !== undefined
125
+ ? undefined
126
+ : 'kickstart takes only -k / -p, then `system/<label>`';
127
+ }
128
+ return `launchctl ${String(sub)} is never run as root`;
129
+ };
130
+
131
+ const ID = /^\d+$/;
132
+
133
+ /** `install`'s optional owner flags: none, `-o N`, `-g N`, or `-o N -g N`. Numeric ids only. */
134
+ const idsOk = (ids: readonly string[]): boolean => {
135
+ const [flag, id, second, secondId] = ids;
136
+ if (ids.length === 0) return true;
137
+ if (ids.length === 2) return (flag === '-o' || flag === '-g') && ID.test(id ?? '');
138
+ return (
139
+ ids.length === 4 &&
140
+ flag === '-o' &&
141
+ ID.test(id ?? '') &&
142
+ second === '-g' &&
143
+ ID.test(secondId ?? '')
144
+ );
145
+ };
146
+
147
+ /** The one source `install` may copy and the prefixes, for the call being checked. */
148
+ export type AllowContext = { readonly prefixes: readonly string[]; readonly staged?: string };
149
+
150
+ /** `undefined` when `argv` is exactly an allowed privileged shape, else the reason it is not. */
151
+ export const privilegedProblem = (
152
+ argv: readonly string[],
153
+ context: AllowContext,
154
+ ): string | undefined => {
155
+ const [program, ...args] = argv;
156
+ const under = (path: string | undefined) =>
157
+ path !== undefined && prefixOf(path, context.prefixes) !== undefined;
158
+ if (program === LAUNCHCTL) return launchctlProblem(args, context.prefixes);
159
+ if (program === INSTALL) {
160
+ // -S -m MODE [-o UID] [-g GID] STAGED DEST: nothing else, in this order.
161
+ const [safe, m, mode, ...rest] = args;
162
+ if (safe !== '-S' || m !== '-m' || mode === undefined || !/^[0-7]{4}$/.test(mode)) {
163
+ return 'install takes exactly `-S -m <4 octal digits> [-o <uid>] [-g <gid>] <staged> <dest>`';
164
+ }
165
+ if (rest.length < 2 || !idsOk(rest.slice(0, -2))) return 'install takes only -o <uid> -g <gid>';
166
+ const [source, dest] = rest.slice(-2);
167
+ if (source === undefined || source !== context.staged) {
168
+ return 'install copies only the file this runner staged';
169
+ }
170
+ return under(dest) ? undefined : 'install destination is not under a declared prefix';
171
+ }
172
+ if (program === RM) {
173
+ const [force, end, path, ...extra] = args;
174
+ return force === '-f' && end === '--' && extra.length === 0 && under(path)
175
+ ? undefined
176
+ : 'rm takes exactly `-f -- <path under a declared prefix>`';
177
+ }
178
+ return `${String(program)} is not on the sudo allowlist`;
179
+ };
180
+
181
+ /** Where one exec goes: run as the deploying user, run as root through the allowlist, or refused. */
182
+ export type Route =
183
+ | { readonly as: 'operator' }
184
+ | { readonly as: 'root' }
185
+ | { readonly refuse: string };
186
+
187
+ /**
188
+ * ★ WHY THIS REFUSES AS WELL AS ROUTES. sudoRunner reports `privileged: true`, which switches off
189
+ * the providers' own "another user's gui domain needs root" refusal (job-preflight.ts). This
190
+ * runner elevates only the system domain, so it gives that refusal back itself. (A write into
191
+ * another user's LaunchAgents is refused by the writer first: outside every prefix, a foreign
192
+ * owner needs root. This covers the delete, which reaches launchctl before any file.)
193
+ * ⚠️ Reads (`print`, `print-disabled`) always run as the deploying user: launchctl(1) says anyone
194
+ * may read or query the system domain, and launchctl.ts measured it on 2026-09-21.
195
+ */
196
+ export const routeExec = (argv: readonly string[], operatorUid: number): Route => {
197
+ const [program, sub] = argv;
198
+ if (program === 'sudo' || program?.endsWith('/sudo') === true) {
199
+ return {
200
+ refuse: 'sudoRunner never passes a sudo argv through; it elevates only its allowlist',
201
+ };
202
+ }
203
+ if (program !== LAUNCHCTL || sub === undefined || !WRITES.has(sub)) return { as: 'operator' };
204
+ const target = sub === 'kickstart' ? argv[argv.length - 1] : argv[2];
205
+ if (target === 'system' || target?.startsWith('system/') === true) return { as: 'root' };
206
+ const owner = /^(?:gui|user)\/(\d+)(?:\/|$)/.exec(target ?? '')?.[1];
207
+ if (owner === undefined || Number(owner) === operatorUid) return { as: 'operator' };
208
+ return {
209
+ refuse:
210
+ `launchctl ${sub} ${String(target)}: that domain belongs to uid ${owner}, and sudoRunner ` +
211
+ 'elevates only the system domain. Deploy as that user, or as root.',
212
+ };
213
+ };
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The guard's pure parts, the `id -G` parse, and the real staging code on a real temp directory.
3
+ * Nothing here runs sudo or writes outside `os.tmpdir()`.
4
+ */
5
+ import { describe, expect, test } from 'bun:test';
6
+ import { stat } from 'node:fs/promises';
7
+ import { dirname } from 'node:path';
8
+ import { fakeRunner } from './fake-runner.ts';
9
+ import type { ExecResult } from './runner.ts';
10
+ import { betweenDirs, canRead, operatorGroups } from './sudo-guard.ts';
11
+ import { stageFile } from './sudo-stage.ts';
12
+
13
+ describe('canRead', () => {
14
+ const file = (uid: number, gid: number, mode: number) => ({ gid, mode, uid });
15
+ test.each([
16
+ ['root reads anything', file(0, 0, 0o000), 0, [], true],
17
+ ['the owner gets the owner bits only', file(501, 20, 0o044), 501, [20], false],
18
+ ['a member gets the group bits', file(0, 80, 0o640), 501, [80], true],
19
+ // ⚠️ Exactly one class applies: a member of the group does not fall through to "other".
20
+ ['a member is hidden by 0604', file(0, 80, 0o604), 501, [80], false],
21
+ ['a non-member gets the other bits', file(0, 80, 0o604), 501, [20], true],
22
+ ['0640 hides it from a non-member', file(0, 0, 0o640), 501, [20, 80], false],
23
+ ['0600 root-only hides it', file(0, 0, 0o600), 501, [0], false],
24
+ ['0644 is readable whatever the groups', file(0, 0, 0o644), 501, undefined, true],
25
+ ['0600 is unreadable whatever the groups', file(0, 0, 0o600), 501, undefined, false],
26
+ [
27
+ 'only membership could decide, and it is unknown',
28
+ file(0, 80, 0o640),
29
+ 501,
30
+ undefined,
31
+ undefined,
32
+ ],
33
+ ] as const)('%s', (_name, f, uid, groups, expected) => {
34
+ expect(canRead(f, uid, groups)).toBe(expected);
35
+ });
36
+ });
37
+
38
+ test('betweenDirs lists every directory strictly between the prefix and the file', () => {
39
+ expect(betweenDirs('/opt/example', '/opt/example/a.conf')).toEqual([]);
40
+ expect(betweenDirs('/opt/example', '/opt/example/app/etc/a.conf')).toEqual([
41
+ '/opt/example/app',
42
+ '/opt/example/app/etc',
43
+ ]);
44
+ });
45
+
46
+ describe('operatorGroups', () => {
47
+ const withId = (result: ExecResult) => {
48
+ const { runner } = fakeRunner();
49
+ const calls: string[][] = [];
50
+ return {
51
+ calls,
52
+ runner: {
53
+ ...runner,
54
+ exec: async (argv: readonly string[]) => (calls.push([...argv]), result),
55
+ },
56
+ };
57
+ };
58
+
59
+ test('parses `id -G`, run as the operator with an absolute path', async () => {
60
+ const host = withId({ exitCode: 0, stderr: '', stdout: '20 12 61 80 400 702\n' });
61
+ expect(await operatorGroups(host.runner)).toEqual([20, 12, 61, 80, 400, 702]);
62
+ expect(host.calls).toEqual([['/usr/bin/id', '-G']]);
63
+ });
64
+
65
+ test.each([
66
+ ['a failed lookup, whatever it printed', { exitCode: 1, stderr: 'id: failed', stdout: '20\n' }],
67
+ ['empty output', { exitCode: 0, stderr: '', stdout: '\n' }],
68
+ ['anything but numbers', { exitCode: 0, stderr: '', stdout: '20 staff\n' }],
69
+ ])('%s is unknown, never an empty list', async (_name, result) => {
70
+ expect(await operatorGroups(withId(result).runner)).toBeUndefined();
71
+ });
72
+ });
73
+
74
+ describe('stageFile (real temp directory)', () => {
75
+ test('0600 file in a 0700 directory, exact bytes, gone after dispose', async () => {
76
+ const staged = await stageFile(new TextEncoder().encode('listen: 127.0.0.1:9100\n'));
77
+ try {
78
+ expect(await Bun.file(staged.path).text()).toBe('listen: 127.0.0.1:9100\n');
79
+ expect((await stat(staged.path)).mode & 0o777).toBe(0o600);
80
+ expect((await stat(dirname(staged.path))).mode & 0o777).toBe(0o700);
81
+ } finally {
82
+ await staged.dispose();
83
+ }
84
+ // ⚠️ stat, not Bun.file().exists(): measured 2026-09-21 on bun 1.4.0, exists() is false for a
85
+ // directory that DOES exist, so it could never catch a leaked staging directory.
86
+ await expect(stat(dirname(staged.path))).rejects.toThrow('ENOENT');
87
+ });
88
+
89
+ test('two stagings never share a file', async () => {
90
+ const [a, b] = await Promise.all([
91
+ stageFile(new Uint8Array([1])),
92
+ stageFile(new Uint8Array([2])),
93
+ ]);
94
+ try {
95
+ expect(a?.path).not.toBe(b?.path);
96
+ } finally {
97
+ await Promise.all([a?.dispose(), b?.dispose()]);
98
+ }
99
+ });
100
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The checks sudo-runner.ts makes against the host, as the deploying user, before it runs anything
3
+ * as root. sudo-allowlist.ts checks the argv; this checks what the paths in it actually are.
4
+ *
5
+ * ⛔ WHAT IS LOGGED IS WHAT IS WRITTEN. A symlinked directory under a prefix would make
6
+ * `install` or `rm`, running as root, act on a path the log never named. So every directory
7
+ * between the prefix and the file must be a real directory.
8
+ * ⚠️ These checks run before the privileged call, so they guard against a mistaken declaration, not
9
+ * against someone who can already write inside a prefix and swap a path in between. The prefix
10
+ * itself is checked to be root-only (prefixProblem); a directory BELOW it that another user owns
11
+ * is not, and that user could swap what lies under it in that window.
12
+ */
13
+ import type { FileStat, HostRunner, WriteOptions } from './runner.ts';
14
+ import { SudoRefusedError } from './sudo-allowlist.ts';
15
+
16
+ const refuse = (path: string, message: string): Error =>
17
+ new SudoRefusedError(`sudoRunner ${path}: ${message}`);
18
+
19
+ /** The directories strictly between `prefix` and the file at `path`. */
20
+ export const betweenDirs = (prefix: string, path: string): string[] => {
21
+ const parts = path
22
+ .slice(prefix.length + 1)
23
+ .split('/')
24
+ .slice(0, -1);
25
+ return parts.map((_, index) => `${prefix}/${parts.slice(0, index + 1).join('/')}`);
26
+ };
27
+
28
+ /**
29
+ * What must be at `path` itself: a regular file, or (for a write or a remove) nothing yet.
30
+ * ⚠️ A DIRECTORY AT THE DESTINATION IS THE TRAP: `install src dir` copies INTO the directory
31
+ * (install(1)), and install decides that with stat, so a symlink to a directory does the same.
32
+ */
33
+ export type Expect = 'file' | 'file-or-absent';
34
+
35
+ /**
36
+ * ⛔ THE PREFIX ITSELF MUST BE A DIRECTORY ONLY ROOT CAN CHANGE, checked at every call rather than
37
+ * trusted from the declaration. A symlinked prefix makes root write wherever it points, which the
38
+ * log never names (`/opt/example` -> `/etc` turns a HostFile at `/opt/example/sudoers` into
39
+ * `/etc/sudoers`). A prefix another user owns or may write lets that user swap a directory below
40
+ * it for a symlink between this check and the privileged call.
41
+ * ⚠️ lstat of the prefix itself: a symlink ABOVE it (`/etc` -> `/private/etc` on macOS) is the
42
+ * system's and is followed, as every path lookup does. ACLs are not read; mode bits only.
43
+ * ★ MEASURED 2026-09-21 on macOS 27.2 (`ls -ldn`): /Library/LaunchDaemons, /private/etc, /opt and
44
+ * /usr/local are uid 0, mode 0755, so the prefixes a host stack declares pass as they are.
45
+ */
46
+ const prefixProblem = (stat: FileStat | undefined): string | undefined => {
47
+ if (stat === undefined) return 'is missing';
48
+ if (stat.kind !== 'directory') return `is a ${stat.kind}, not a directory`;
49
+ if (stat.uid !== 0) return `is owned by uid ${String(stat.uid)}, not root`;
50
+ if ((stat.mode & 0o022) !== 0) {
51
+ return `is writable by group or other (mode ${stat.mode.toString(8).padStart(4, '0')})`;
52
+ }
53
+ return undefined;
54
+ };
55
+
56
+ /**
57
+ * Refuse a prefix that is not a root-only directory, a symlink or a non-directory between the
58
+ * prefix and `path`, and anything but a file at it.
59
+ */
60
+ export const assertPlainPath = async (
61
+ base: HostRunner,
62
+ prefix: string,
63
+ path: string,
64
+ expect: Expect,
65
+ ): Promise<'file' | 'absent'> => {
66
+ const dirs = betweenDirs(prefix, path);
67
+ const [prefixStat, ...stats] = await Promise.all([prefix, ...dirs].map((dir) => base.stat(dir)));
68
+ const bad = prefixProblem(prefixStat);
69
+ if (bad !== undefined) {
70
+ throw refuse(
71
+ path,
72
+ `the declared prefix ${prefix} ${bad}. Declare only a real directory that root owns and ` +
73
+ 'only root may write. Nothing ran as root.',
74
+ );
75
+ }
76
+ for (const [index, stat] of stats.entries()) {
77
+ if (stat?.kind !== 'directory') {
78
+ throw refuse(
79
+ path,
80
+ `${String(dirs[index])} is ${stat === undefined ? 'missing' : `a ${stat.kind}`}; root ` +
81
+ 'writes only through real directories under a declared prefix.',
82
+ );
83
+ }
84
+ }
85
+ const target = await base.stat(path);
86
+ if (target === undefined && expect === 'file-or-absent') return 'absent';
87
+ if (target?.kind === 'file') return 'file';
88
+ throw refuse(path, `is ${target === undefined ? 'missing' : `a ${target.kind}`}, not a file`);
89
+ };
90
+
91
+ /**
92
+ * POSIX read permission for one uid: owner class, else group class, else other. `undefined` when
93
+ * only group membership could decide and the groups are unknown.
94
+ * ⚠️ EXACTLY ONE CLASS APPLIES. A group member gets the group bits even when the other bits are
95
+ * wider (`0604` hides a file from its group), so "other can read" is not enough on its own.
96
+ */
97
+ export const canRead = (
98
+ file: { readonly uid: number; readonly gid: number; readonly mode: number },
99
+ uid: number,
100
+ groups: readonly number[] | undefined,
101
+ ): boolean | undefined => {
102
+ if (uid === 0) return true;
103
+ if (file.uid === uid) return (file.mode & 0o400) !== 0;
104
+ const groupRead = (file.mode & 0o040) !== 0;
105
+ const otherRead = (file.mode & 0o004) !== 0;
106
+ // Membership cannot change the answer when both classes agree.
107
+ if (groupRead === otherRead) return groupRead;
108
+ if (groups === undefined) return undefined;
109
+ return groups.includes(file.gid) ? groupRead : otherRead;
110
+ };
111
+
112
+ /**
113
+ * ⛔ REFUSE A FILE THE DEPLOYING USER COULD NOT READ BACK, before writing it. Both providers read
114
+ * every write back to verify it (host-file-lifecycle.ts, job-lifecycle.ts) and read it again at
115
+ * every plan, as the deploying user; this runner elevates writes, never reads. A root-only
116
+ * `0600` would be installed, then fail the read-back with EACCES and stay on disk with no state,
117
+ * so every later plan would fail too.
118
+ * ★ An omitted owner is root (under a prefix the runner writes as root). An omitted group is the
119
+ * parent directory's, which is the group a new file gets on macOS.
120
+ * ⚠️ Unknown groups never refuse: the read-back after the write stays the final judge.
121
+ */
122
+ export const assertReadableBack = async (
123
+ base: HostRunner,
124
+ path: string,
125
+ options: WriteOptions,
126
+ groups: readonly number[] | undefined,
127
+ ): Promise<void> => {
128
+ const operator = base.effectiveUid();
129
+ const parent = path.slice(0, path.lastIndexOf('/')) || '/';
130
+ const gid = options.gid ?? (await base.stat(parent))?.gid ?? 0;
131
+ const file = { gid, mode: options.mode, uid: options.uid ?? 0 };
132
+ if (canRead(file, operator, groups) !== false) return;
133
+ throw refuse(
134
+ path,
135
+ `owner ${String(file.uid)}, group ${String(gid)}, mode ${file.mode.toString(8)} would hide it ` +
136
+ `from the deploying user (uid ${String(operator)}), who reads every file back. Declare a ` +
137
+ 'mode that user can read, or deploy as root. Nothing was written.',
138
+ );
139
+ };
140
+
141
+ /**
142
+ * The deploying user's groups, from `id -G`; `undefined` when that fails.
143
+ * ⚠️ NOT process.getgroups(). MEASURED 2026-09-21 on macOS 27.2: getgroups() returned 16 groups
144
+ * where `id -G` returned 18, because macOS caps a process's own list at 16. A group past the
145
+ * 16th would read as "not a member" and refuse a file the user can in fact read.
146
+ */
147
+ export const operatorGroups = async (base: HostRunner): Promise<readonly number[] | undefined> => {
148
+ const result = await base.exec(['/usr/bin/id', '-G']);
149
+ const ids = result.stdout.trim().split(/\s+/);
150
+ return result.exitCode === 0 && ids.every((id) => /^\d+$/.test(id)) ? ids.map(Number) : undefined;
151
+ };