@homeflare/alchemy 0.7.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.
- package/README.md +26 -37
- package/dist/caddy/config-lifecycle.d.ts +49 -3
- package/dist/caddy/config-lifecycle.d.ts.map +1 -1
- package/dist/caddy/config.d.ts +6 -2
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/fake-caddy.d.ts +11 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -1
- package/dist/caddy/index.d.ts +2 -1
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +71 -13
- package/dist/caddy/index.js.map +9 -9
- package/dist/caddy/with-file.d.ts +7 -3
- package/dist/caddy/with-file.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +50 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +18 -9
- package/dist/launchd/host-effect.d.ts.map +1 -1
- package/dist/launchd/host-file-form.d.ts +5 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +2 -0
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +342 -8
- package/dist/launchd/index.js.map +15 -11
- package/dist/launchd/job-preflight.d.ts.map +1 -1
- package/dist/launchd/job-validate.d.ts +2 -0
- package/dist/launchd/job-validate.d.ts.map +1 -1
- package/dist/launchd/local-runner.d.ts +2 -0
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/providers.d.ts +2 -1
- package/dist/launchd/providers.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +7 -5
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-allowlist.d.ts +47 -0
- package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +56 -0
- package/dist/launchd/sudo-guard.d.ts.map +1 -0
- package/dist/launchd/sudo-runner.d.ts +60 -0
- package/dist/launchd/sudo-runner.d.ts.map +1 -0
- package/dist/launchd/sudo-stage.d.ts +7 -0
- package/dist/launchd/sudo-stage.d.ts.map +1 -0
- package/dist/openbao/auth-role-form.d.ts +0 -9
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts +3 -2
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/fake-engines-roles.d.ts +32 -0
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
- package/dist/openbao/fake-engines.d.ts +8 -2
- package/dist/openbao/fake-engines.d.ts.map +1 -1
- package/dist/openbao/fake-families.d.ts +49 -0
- package/dist/openbao/fake-families.d.ts.map +1 -0
- package/dist/openbao/host-approles.d.ts.map +1 -1
- package/dist/openbao/index.js +359 -280
- package/dist/openbao/index.js.map +21 -19
- package/dist/openbao/jwt-config.d.ts +5 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts +2 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts +2 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +6 -3
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/rename-identity.d.ts +69 -0
- package/dist/openbao/rename-identity.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +13 -12
- package/dist/openbao/rename.d.ts.map +1 -1
- package/dist/openbao/ssh-role-erase.d.ts +11 -0
- package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/docs/caddy-admin.md +24 -0
- package/docs/caddy.md +73 -54
- package/docs/launchd-sudo.md +122 -0
- package/docs/launchd.md +7 -4
- package/docs/peers.md +39 -0
- package/package.json +1 -1
- package/src/caddy/adopt-scope.test.ts +82 -0
- package/src/caddy/config-adopt.test.ts +235 -0
- package/src/caddy/config-lifecycle.test.ts +31 -53
- package/src/caddy/config-lifecycle.ts +98 -7
- package/src/caddy/config.ts +48 -11
- package/src/caddy/fake-caddy.ts +19 -0
- package/src/caddy/index.ts +2 -1
- package/src/caddy/providers.test.ts +8 -23
- package/src/caddy/with-file.ts +7 -3
- package/src/launchd/fake-sudo.ts +136 -0
- package/src/launchd/host-effect.ts +33 -1
- package/src/launchd/host-file-form.ts +10 -2
- package/src/launchd/host-file-lifecycle.ts +2 -1
- package/src/launchd/index.ts +2 -0
- package/src/launchd/job-preflight.ts +3 -2
- package/src/launchd/job-validate.ts +1 -1
- package/src/launchd/local-runner.ts +6 -3
- package/src/launchd/providers.test.ts +3 -0
- package/src/launchd/providers.ts +2 -1
- package/src/launchd/runner.ts +7 -5
- package/src/launchd/sudo-allowlist.test.ts +191 -0
- package/src/launchd/sudo-allowlist.ts +213 -0
- package/src/launchd/sudo-guard.test.ts +100 -0
- package/src/launchd/sudo-guard.ts +151 -0
- package/src/launchd/sudo-lifecycle.test.ts +172 -0
- package/src/launchd/sudo-runner-files.test.ts +240 -0
- package/src/launchd/sudo-runner.test.ts +126 -0
- package/src/launchd/sudo-runner.ts +235 -0
- package/src/launchd/sudo-stage.ts +28 -0
- package/src/openbao/README.md +8 -2
- package/src/openbao/REPLACE.md +65 -37
- package/src/openbao/auth-role-form.test.ts +2 -16
- package/src/openbao/auth-role-form.ts +0 -11
- package/src/openbao/auth-role.ts +35 -14
- package/src/openbao/cloudflare-role.ts +6 -14
- package/src/openbao/fake-engines-roles.ts +114 -0
- package/src/openbao/fake-engines.ts +18 -11
- package/src/openbao/fake-families.ts +182 -0
- package/src/openbao/host-approles.test.ts +15 -2
- package/src/openbao/host-approles.ts +19 -4
- package/src/openbao/jwt-config.ts +26 -5
- package/src/openbao/jwt-role.ts +24 -8
- package/src/openbao/kubernetes-role.ts +20 -8
- package/src/openbao/mfa-enforcement.ts +23 -8
- package/src/openbao/mfa-totp.ts +21 -8
- package/src/openbao/pki-role.ts +19 -7
- package/src/openbao/plugin.ts +41 -15
- package/src/openbao/policy.ts +7 -7
- package/src/openbao/proxmox-role.ts +7 -16
- package/src/openbao/rename-adoption.test.ts +82 -0
- package/src/openbao/rename-families.test.ts +122 -0
- package/src/openbao/rename-identity.test.ts +129 -0
- package/src/openbao/rename-identity.ts +117 -0
- package/src/openbao/rename.ts +19 -15
- package/src/openbao/ssh-role-erase.ts +50 -0
- package/src/openbao/ssh-role.ts +16 -54
|
@@ -212,11 +212,14 @@ describe('the barrel', () => {
|
|
|
212
212
|
'LaunchdJob',
|
|
213
213
|
'LaunchdJobProvider',
|
|
214
214
|
'PlistError',
|
|
215
|
+
'SudoRefusedError',
|
|
215
216
|
'canActAsRoot',
|
|
216
217
|
'hostRunnerLayer',
|
|
217
218
|
'launchdProviders',
|
|
218
219
|
'localRunner',
|
|
219
220
|
'renderPlist',
|
|
221
|
+
// ★ The opt-in elevating runner; makeSudoRunner and the allowlist internals stay off.
|
|
222
|
+
'sudoRunner',
|
|
220
223
|
]);
|
|
221
224
|
});
|
|
222
225
|
});
|
package/src/launchd/providers.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* written by the same identity on the same host — a stack cannot write the file as one user and
|
|
8
8
|
* bootstrap the job as another by accident.
|
|
9
9
|
* ⚠️ The default is localRunner(): this process, never elevated. A system-domain job therefore
|
|
10
|
-
* needs the deploy itself started as root, or a runner passed here with `privileged: true
|
|
10
|
+
* needs the deploy itself started as root, or a runner passed here with `privileged: true` —
|
|
11
|
+
* `launchdProviders(sudoRunner({ prefixes }))` is the kit's own (sudo-runner.ts).
|
|
11
12
|
*/
|
|
12
13
|
import * as Layer from 'effect/Layer';
|
|
13
14
|
import { HostFileProvider } from './host-file.ts';
|
package/src/launchd/runner.ts
CHANGED
|
@@ -48,13 +48,15 @@ export type HostUser = { readonly uid: number; readonly gid: number; readonly ho
|
|
|
48
48
|
|
|
49
49
|
export interface HostRunner {
|
|
50
50
|
/**
|
|
51
|
-
* ⛔ DECLARED, NEVER INFERRED. `true` only when
|
|
52
|
-
*
|
|
51
|
+
* ⛔ DECLARED, NEVER INFERRED. `true` only when this runner can do every root-needing call the
|
|
52
|
+
* providers make of it: every call already runs as root (a runner that talks to a root helper,
|
|
53
|
+
* say), or the runner elevates exactly those calls (sudoRunner). The providers refuse
|
|
53
54
|
* system-domain writes unless `effectiveUid()` is 0 or this is `true`, so claiming it falsely
|
|
54
55
|
* turns that refusal into an EACCES halfway through a deploy.
|
|
55
|
-
* ⛔ THE KIT NEVER ELEVATES.
|
|
56
|
-
* mid-plan, or
|
|
57
|
-
*
|
|
56
|
+
* ⛔ THE KIT NEVER ELEVATES SILENTLY. localRunner() never calls `sudo`: a deploy that prompts for
|
|
57
|
+
* a password mid-plan, or quietly borrows a cached sudo ticket, is exactly the "no silent sudo"
|
|
58
|
+
* failure. sudoRunner() does call `sudo -n`, but only when a stack passes it explicitly, only
|
|
59
|
+
* for the argv shapes in sudo-allowlist.ts, and logging each one (decided 2026-09-21).
|
|
58
60
|
*/
|
|
59
61
|
readonly privileged: boolean;
|
|
60
62
|
/** The uid file writes and launchctl calls run as. */
|
|
@@ -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
|
+
});
|