@homeflare/alchemy 0.7.0 → 0.10.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 +45 -45
- 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 +270 -44
- package/dist/caddy/index.js.map +15 -11
- package/dist/caddy/with-file.d.ts +7 -3
- package/dist/caddy/with-file.d.ts.map +1 -1
- package/dist/launchd/fake-runner.d.ts +1 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +51 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +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 +14 -1
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/host-file.d.ts +2 -1
- package/dist/launchd/host-file.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 +642 -49
- package/dist/launchd/index.js.map +24 -14
- package/dist/launchd/job-lifecycle.d.ts +8 -1
- package/dist/launchd/job-lifecycle.d.ts.map +1 -1
- package/dist/launchd/job-preflight.d.ts +13 -1
- 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/job.d.ts +2 -1
- package/dist/launchd/job.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 +14 -5
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-acl.d.ts +25 -0
- package/dist/launchd/sudo-acl.d.ts.map +1 -0
- 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 +83 -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-said.d.ts +10 -0
- package/dist/launchd/sudo-said.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-method-reconcile.d.ts +6 -2
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
- package/dist/openbao/auth-method.d.ts.map +1 -1
- 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-bao.d.ts +18 -0
- package/dist/openbao/fake-bao.d.ts.map +1 -1
- package/dist/openbao/fake-core.d.ts +39 -0
- package/dist/openbao/fake-core.d.ts.map +1 -0
- 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 +52 -0
- package/dist/openbao/fake-families.d.ts.map +1 -0
- package/dist/openbao/fake-mounts.d.ts +16 -0
- package/dist/openbao/fake-mounts.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +5 -1
- package/dist/openbao/fake-stack.d.ts.map +1 -1
- package/dist/openbao/host-approles.d.ts.map +1 -1
- package/dist/openbao/index.js +709 -359
- package/dist/openbao/index.js.map +32 -25
- 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 +12 -5
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts +6 -2
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/mount-reconcile.d.ts +5 -1
- package/dist/openbao/mount-reconcile.d.ts.map +1 -1
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin-reconcile.d.ts +6 -2
- package/dist/openbao/plugin-reconcile.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/role-reconcile.d.ts +11 -1
- package/dist/openbao/role-reconcile.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/dist/ownership/adopt.d.ts +52 -0
- package/dist/ownership/adopt.d.ts.map +1 -0
- package/dist/ownership/probe.d.ts +13 -0
- package/dist/ownership/probe.d.ts.map +1 -0
- package/dist/ownership/resume.d.ts +40 -0
- package/dist/ownership/resume.d.ts.map +1 -0
- package/dist/ownership/rows.d.ts +33 -0
- package/dist/ownership/rows.d.ts.map +1 -0
- package/dist/ownership/whole.d.ts +14 -0
- package/dist/ownership/whole.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool-form.d.ts +3 -3
- package/dist/proxmox/ceph-pool.d.ts.map +1 -1
- package/dist/proxmox/fake-bao-env.d.ts +3 -0
- package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
- package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
- package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
- package/dist/proxmox/fake-pve.d.ts +38 -0
- package/dist/proxmox/fake-pve.d.ts.map +1 -0
- package/dist/proxmox/index.d.ts +6 -2
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +970 -186
- package/dist/proxmox/index.js.map +22 -9
- package/dist/proxmox/lxc-create-form.d.ts +6 -0
- package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
- package/dist/proxmox/lxc-harness.d.ts +34 -0
- package/dist/proxmox/lxc-harness.d.ts.map +1 -0
- package/dist/proxmox/lxc-identity.d.ts +18 -0
- package/dist/proxmox/lxc-identity.d.ts.map +1 -0
- package/dist/proxmox/lxc-judge.d.ts +46 -0
- package/dist/proxmox/lxc-judge.d.ts.map +1 -0
- package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/lxc-props.d.ts +139 -0
- package/dist/proxmox/lxc-props.d.ts.map +1 -0
- package/dist/proxmox/lxc-volume.d.ts +29 -0
- package/dist/proxmox/lxc-volume.d.ts.map +1 -0
- package/dist/proxmox/lxc-wire.d.ts +51 -0
- package/dist/proxmox/lxc-wire.d.ts.map +1 -0
- package/dist/proxmox/lxc.d.ts +27 -47
- package/dist/proxmox/lxc.d.ts.map +1 -1
- package/dist/proxmox/network-apply-read.d.ts +1 -1
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/update-guard.d.ts +27 -0
- package/dist/proxmox/update-guard.d.ts.map +1 -0
- package/dist/verify/args.d.ts +16 -0
- package/dist/verify/args.d.ts.map +1 -0
- package/dist/verify/cli.d.ts +3 -0
- package/dist/verify/cli.d.ts.map +1 -0
- package/dist/verify/cli.js +320 -0
- package/dist/verify/cli.js.map +15 -0
- package/dist/verify/fake-engine.d.ts +57 -0
- package/dist/verify/fake-engine.d.ts.map +1 -0
- package/dist/verify/index.d.ts +16 -0
- package/dist/verify/index.d.ts.map +1 -0
- package/dist/verify/index.js +297 -0
- package/dist/verify/index.js.map +14 -0
- package/dist/verify/report.d.ts +6 -0
- package/dist/verify/report.d.ts.map +1 -0
- package/dist/verify/rows.d.ts +57 -0
- package/dist/verify/rows.d.ts.map +1 -0
- package/dist/verify/spy.d.ts +31 -0
- package/dist/verify/spy.d.ts.map +1 -0
- package/dist/verify/verify.d.ts +43 -0
- package/dist/verify/verify.d.ts.map +1 -0
- package/docs/adopt-verify.md +108 -0
- package/docs/adopted-deploys.md +85 -0
- package/docs/caddy-admin.md +24 -0
- package/docs/caddy.md +73 -54
- package/docs/credentials.md +15 -0
- package/docs/launchd-sudo.md +147 -0
- package/docs/launchd.md +14 -7
- package/docs/ownership.md +113 -0
- package/docs/peers.md +39 -0
- package/docs/proxmox-lxc.md +196 -0
- package/docs/proxmox.md +12 -0
- package/package.json +9 -2
- 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 -10
- 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/adopt-apply.test.ts +166 -0
- package/src/launchd/fake-runner.ts +26 -4
- package/src/launchd/fake-sudo.ts +136 -0
- package/src/launchd/host-effect.ts +1 -1
- package/src/launchd/host-file-form.ts +10 -2
- package/src/launchd/host-file-lifecycle.ts +41 -15
- package/src/launchd/host-file.ts +13 -4
- package/src/launchd/index.ts +2 -0
- package/src/launchd/job-lifecycle.ts +22 -14
- package/src/launchd/job-preflight.ts +21 -3
- package/src/launchd/job-validate.ts +1 -1
- package/src/launchd/job.ts +13 -4
- 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 +14 -5
- package/src/launchd/sudo-acl.ts +68 -0
- package/src/launchd/sudo-allowlist.test.ts +191 -0
- package/src/launchd/sudo-allowlist.ts +213 -0
- package/src/launchd/sudo-chain.test.ts +121 -0
- package/src/launchd/sudo-guard.test.ts +100 -0
- package/src/launchd/sudo-guard.ts +230 -0
- package/src/launchd/sudo-lifecycle.test.ts +172 -0
- package/src/launchd/sudo-modes.test.ts +135 -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 +216 -0
- package/src/launchd/sudo-said.ts +58 -0
- package/src/launchd/sudo-stage.ts +28 -0
- package/src/openbao/README.md +11 -2
- package/src/openbao/REPLACE.md +67 -37
- package/src/openbao/adopt-core.test.ts +119 -0
- package/src/openbao/adopt-families.test.ts +97 -0
- package/src/openbao/adopt-holes.test.ts +168 -0
- package/src/openbao/adopt-recovery.test.ts +71 -0
- package/src/openbao/auth-method-reconcile.ts +15 -3
- package/src/openbao/auth-method.ts +15 -6
- 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 +48 -17
- package/src/openbao/cloudflare-role.ts +23 -18
- package/src/openbao/fake-bao.ts +52 -0
- package/src/openbao/fake-core.ts +141 -0
- package/src/openbao/fake-engines-roles.ts +114 -0
- package/src/openbao/fake-engines.ts +18 -11
- package/src/openbao/fake-families.ts +188 -0
- package/src/openbao/fake-mounts.ts +74 -0
- package/src/openbao/fake-stack.ts +17 -4
- package/src/openbao/host-approles.test.ts +15 -2
- package/src/openbao/host-approles.ts +19 -4
- package/src/openbao/jwt-config.ts +35 -11
- package/src/openbao/jwt-role.ts +33 -14
- package/src/openbao/kubernetes-role.ts +32 -14
- package/src/openbao/mfa-enforcement.ts +42 -13
- package/src/openbao/mfa-totp.ts +42 -14
- package/src/openbao/mount-move.test.ts +2 -35
- package/src/openbao/mount-reconcile.ts +13 -2
- package/src/openbao/mount.ts +15 -6
- package/src/openbao/pki-role.ts +33 -10
- package/src/openbao/plugin-reconcile.ts +16 -5
- package/src/openbao/plugin.ts +57 -20
- package/src/openbao/policy.ts +24 -11
- package/src/openbao/proxmox-role.ts +20 -20
- package/src/openbao/rename-adoption.test.ts +95 -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/role-reconcile.ts +23 -0
- package/src/openbao/ssh-role-erase.ts +50 -0
- package/src/openbao/ssh-role.ts +29 -57
- package/src/ownership/adopt.ts +122 -0
- package/src/ownership/ownership.test.ts +200 -0
- package/src/ownership/probe.ts +65 -0
- package/src/ownership/resume.ts +118 -0
- package/src/ownership/rows.ts +117 -0
- package/src/ownership/whole.test.ts +39 -0
- package/src/ownership/whole.ts +60 -0
- package/src/proxmox/adopt-noop.test.ts +112 -0
- package/src/proxmox/ceph-pool-adopt.test.ts +88 -0
- package/src/proxmox/ceph-pool-form.ts +3 -3
- package/src/proxmox/ceph-pool.ts +10 -3
- package/src/proxmox/fake-bao-env.ts +28 -0
- package/src/proxmox/fake-pve-lxc.ts +210 -0
- package/src/proxmox/fake-pve.ts +75 -0
- package/src/proxmox/index.test.ts +6 -6
- package/src/proxmox/index.ts +6 -2
- package/src/proxmox/lxc-adopt.test.ts +170 -0
- package/src/proxmox/lxc-create-form.ts +76 -0
- package/src/proxmox/lxc-create.test.ts +126 -0
- package/src/proxmox/lxc-guards.test.ts +158 -0
- package/src/proxmox/lxc-harness.ts +150 -0
- package/src/proxmox/lxc-identity.ts +43 -0
- package/src/proxmox/lxc-judge.test.ts +175 -0
- package/src/proxmox/lxc-judge.ts +196 -0
- package/src/proxmox/lxc-lifecycle.ts +200 -0
- package/src/proxmox/lxc-ownership.test.ts +159 -0
- package/src/proxmox/lxc-props.ts +215 -0
- package/src/proxmox/lxc-read.test.ts +60 -0
- package/src/proxmox/lxc-volume.test.ts +96 -0
- package/src/proxmox/lxc-volume.ts +153 -0
- package/src/proxmox/lxc-wire.ts +210 -0
- package/src/proxmox/lxc.ts +216 -93
- package/src/proxmox/network-apply-read.ts +3 -1
- package/src/proxmox/pbs-datastore.ts +4 -3
- package/src/proxmox/resource.ts +7 -7
- package/src/proxmox/update-guard.ts +31 -0
- package/src/verify/args.ts +82 -0
- package/src/verify/cli.test.ts +86 -0
- package/src/verify/cli.ts +49 -0
- package/src/verify/fake-engine.ts +158 -0
- package/src/verify/index.ts +36 -0
- package/src/verify/report.ts +52 -0
- package/src/verify/rows.ts +169 -0
- package/src/verify/spy.test.ts +141 -0
- package/src/verify/spy.ts +149 -0
- package/src/verify/verify.test.ts +124 -0
- package/src/verify/verify.ts +172 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sudoRunner's file writes and removes over the fake host (fake-sudo.ts): under a prefix they go
|
|
3
|
+
* through `install` / `rm` as root; everything a mistaken declaration could turn into a root write
|
|
4
|
+
* somewhere unintended is refused before anything is staged. No real sudo runs anywhere.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { OPERATOR, fakeSudoHost } from './fake-sudo.ts';
|
|
8
|
+
import type { FileStat } from './runner.ts';
|
|
9
|
+
import { INSTALL, RM, SudoRefusedError } from './sudo-allowlist.ts';
|
|
10
|
+
|
|
11
|
+
const CONF = '/opt/example/app/a.conf';
|
|
12
|
+
const bytes = (text: string) => new TextEncoder().encode(text);
|
|
13
|
+
const plistBytes = bytes('<key>Label</key><string>com.example.a</string>');
|
|
14
|
+
|
|
15
|
+
describe('writeFileAtomic', () => {
|
|
16
|
+
test('under a prefix: staged, installed with mode and owner from argv, staged file removed', async () => {
|
|
17
|
+
const host = fakeSudoHost();
|
|
18
|
+
await host.runner.writeFileAtomic(CONF, bytes('listen: 127.0.0.1:9100\n'), {
|
|
19
|
+
gid: 80,
|
|
20
|
+
mode: 0o640,
|
|
21
|
+
uid: 0,
|
|
22
|
+
});
|
|
23
|
+
const [install] = host.privileged();
|
|
24
|
+
expect(install).toEqual([
|
|
25
|
+
INSTALL,
|
|
26
|
+
'-S',
|
|
27
|
+
'-m',
|
|
28
|
+
'0640',
|
|
29
|
+
'-o',
|
|
30
|
+
'0',
|
|
31
|
+
'-g',
|
|
32
|
+
'80',
|
|
33
|
+
expect.any(String),
|
|
34
|
+
CONF,
|
|
35
|
+
]);
|
|
36
|
+
expect(host.state.installedFrom.get(CONF)).toBe('listen: 127.0.0.1:9100\n');
|
|
37
|
+
expect(host.fake.files.get(CONF)).toMatchObject({ gid: 80, mode: 0o640, uid: 0 });
|
|
38
|
+
expect(host.staged.size).toBe(0);
|
|
39
|
+
// ⛔ The log carries the argv, never the content.
|
|
40
|
+
expect(host.logs.join('\n')).not.toContain('127.0.0.1');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a failed install throws and still removes the staged file', async () => {
|
|
44
|
+
const host = fakeSudoHost();
|
|
45
|
+
host.state.installFailure = { exitCode: 71, stderr: 'install: disk full', stdout: '' };
|
|
46
|
+
await expect(host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o644 })).rejects.toThrow(
|
|
47
|
+
'-> 71: install: disk full',
|
|
48
|
+
);
|
|
49
|
+
expect(host.staged.size).toBe(0);
|
|
50
|
+
expect(host.fake.files.has(CONF)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("outside every prefix, the operator's own file is written by the operator", async () => {
|
|
54
|
+
const host = fakeSudoHost();
|
|
55
|
+
const path = '/Users/someone/Library/LaunchAgents/com.example.a.plist';
|
|
56
|
+
await host.runner.writeFileAtomic(path, plistBytes, { mode: 0o644, uid: OPERATOR });
|
|
57
|
+
expect(host.fake.files.get(path)).toMatchObject({ uid: OPERATOR });
|
|
58
|
+
expect(host.sudoCalls).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('outside every prefix, a foreign owner is refused and nothing is written', async () => {
|
|
62
|
+
const host = fakeSudoHost();
|
|
63
|
+
const path = '/Users/someone/Library/LaunchAgents/x.conf';
|
|
64
|
+
await expect(
|
|
65
|
+
host.runner.writeFileAtomic(path, bytes('x'), { mode: 0o644, uid: 502 }),
|
|
66
|
+
).rejects.toThrow('owner 502 needs root');
|
|
67
|
+
expect(host.fake.files.has(path)).toBe(false);
|
|
68
|
+
expect(host.sudoCalls).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test.each([
|
|
72
|
+
['a directory at the path', '/opt/example/app', 'is a directory'],
|
|
73
|
+
['a missing directory on the way', '/opt/example/none/a.conf', '/opt/example/none is missing'],
|
|
74
|
+
])('%s is refused before anything is staged', async (_name, path, message) => {
|
|
75
|
+
const host = fakeSudoHost();
|
|
76
|
+
await expect(host.runner.writeFileAtomic(path, bytes('x'), { mode: 0o644 })).rejects.toThrow(
|
|
77
|
+
message,
|
|
78
|
+
);
|
|
79
|
+
expect(host.state.stagedCount).toBe(0);
|
|
80
|
+
expect(host.sudoCalls).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a symlinked directory under the prefix is refused', async () => {
|
|
84
|
+
const host = fakeSudoHost();
|
|
85
|
+
host.fake.files.set('/opt/example/link', {
|
|
86
|
+
bytes: bytes(''),
|
|
87
|
+
gid: 0,
|
|
88
|
+
kind: 'symlink',
|
|
89
|
+
mode: 0o755,
|
|
90
|
+
uid: 0,
|
|
91
|
+
});
|
|
92
|
+
await expect(
|
|
93
|
+
host.runner.writeFileAtomic('/opt/example/link/a.conf', bytes('x'), { mode: 0o644 }),
|
|
94
|
+
).rejects.toThrow('/opt/example/link is a symlink');
|
|
95
|
+
expect(host.sudoCalls).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a file the operator could not read back is refused before staging', async () => {
|
|
99
|
+
const host = fakeSudoHost();
|
|
100
|
+
await expect(
|
|
101
|
+
host.runner.writeFileAtomic(CONF, bytes('x'), { gid: 0, mode: 0o600, uid: 0 }),
|
|
102
|
+
).rejects.toThrow('would hide it from the deploying user');
|
|
103
|
+
expect(host.state.stagedCount).toBe(0);
|
|
104
|
+
// Group-readable through one of the operator's groups is fine; so are unknown groups.
|
|
105
|
+
await host.runner.writeFileAtomic(CONF, bytes('x'), { gid: 80, mode: 0o640, uid: 0 });
|
|
106
|
+
await fakeSudoHost('unknown').runner.writeFileAtomic(CONF, bytes('x'), {
|
|
107
|
+
gid: 99,
|
|
108
|
+
mode: 0o640,
|
|
109
|
+
uid: 0,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('an omitted owner is root and an omitted group the directory’s, for the read-back check', async () => {
|
|
114
|
+
// ⚠️ Omitted owner: root, so 0600 is root-only and refused (not the operator's own 0600).
|
|
115
|
+
const host = fakeSudoHost();
|
|
116
|
+
await expect(host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o600 })).rejects.toThrow(
|
|
117
|
+
'would hide it from the deploying user',
|
|
118
|
+
);
|
|
119
|
+
// Omitted group: the parent's (80, one of the operator's), so 0640 is readable and allowed.
|
|
120
|
+
host.fake.files.set('/opt/example/app', {
|
|
121
|
+
bytes: bytes(''),
|
|
122
|
+
gid: 80,
|
|
123
|
+
kind: 'directory',
|
|
124
|
+
mode: 0o755,
|
|
125
|
+
uid: 0,
|
|
126
|
+
});
|
|
127
|
+
await host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o640 });
|
|
128
|
+
expect(host.fake.files.get(CONF)).toMatchObject({ gid: 80, mode: 0o640, uid: 0 });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('a mode chmod cannot mean is a refusal, before anything is staged', async () => {
|
|
132
|
+
const host = fakeSudoHost();
|
|
133
|
+
await expect(
|
|
134
|
+
host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o10000 }),
|
|
135
|
+
).rejects.toBeInstanceOf(SudoRefusedError);
|
|
136
|
+
expect(host.state.stagedCount).toBe(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('outside every prefix, EACCES names the missing prefix instead of a temp file', async () => {
|
|
140
|
+
const host = fakeSudoHost();
|
|
141
|
+
await expect(
|
|
142
|
+
host.runner.writeFileAtomic('/etc/example/a.conf', bytes('x'), { mode: 0o644 }),
|
|
143
|
+
).rejects.toThrow('declare that directory as a prefix');
|
|
144
|
+
await expect(
|
|
145
|
+
host.runner.writeFileAtomic('/nowhere/a.conf', bytes('x'), { mode: 0o644 }),
|
|
146
|
+
).rejects.toThrow('ENOENT');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('the declared prefix itself is checked at every call', () => {
|
|
151
|
+
type Host = ReturnType<typeof fakeSudoHost>;
|
|
152
|
+
const entry = (kind: FileStat['kind'], mode: number) => ({
|
|
153
|
+
bytes: bytes(''),
|
|
154
|
+
gid: 0,
|
|
155
|
+
kind,
|
|
156
|
+
mode,
|
|
157
|
+
uid: 0,
|
|
158
|
+
});
|
|
159
|
+
test.each([
|
|
160
|
+
// ⛔ `/opt/example` -> `/etc` would make root write /etc/a.conf, a path the log never names.
|
|
161
|
+
[
|
|
162
|
+
'a symlink',
|
|
163
|
+
(h: Host) => h.fake.files.set('/opt/example', entry('symlink', 0o755)),
|
|
164
|
+
'is a symlink',
|
|
165
|
+
],
|
|
166
|
+
['owned by the operator', (h: Host) => h.fake.dirs.set('/opt/example', OPERATOR), 'uid 501'],
|
|
167
|
+
[
|
|
168
|
+
'group-writable',
|
|
169
|
+
(h: Host) => h.fake.files.set('/opt/example', entry('directory', 0o775)),
|
|
170
|
+
'0775',
|
|
171
|
+
],
|
|
172
|
+
[
|
|
173
|
+
'world-writable',
|
|
174
|
+
(h: Host) => h.fake.files.set('/opt/example', entry('directory', 0o757)),
|
|
175
|
+
'0757',
|
|
176
|
+
],
|
|
177
|
+
['missing', (h: Host) => h.fake.dirs.delete('/opt/example'), '/opt/example is missing'],
|
|
178
|
+
])('%s is refused, before anything is staged or run', async (_name, setup, message) => {
|
|
179
|
+
const host = fakeSudoHost();
|
|
180
|
+
setup(host);
|
|
181
|
+
const write = host.runner.writeFileAtomic('/opt/example/a.conf', bytes('x'), { mode: 0o644 });
|
|
182
|
+
await expect(write).rejects.toBeInstanceOf(SudoRefusedError);
|
|
183
|
+
await expect(write).rejects.toThrow(message);
|
|
184
|
+
expect(host.state.stagedCount).toBe(0);
|
|
185
|
+
expect(host.sudoCalls).toEqual([]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('a remove under a group-writable prefix is refused too', async () => {
|
|
189
|
+
const host = fakeSudoHost();
|
|
190
|
+
host.fake.files.set('/opt/example', entry('directory', 0o775));
|
|
191
|
+
host.fake.files.set('/opt/example/a.conf', entry('file', 0o644));
|
|
192
|
+
await expect(host.runner.removeFile('/opt/example/a.conf')).rejects.toThrow('0775');
|
|
193
|
+
expect(host.sudoCalls).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('removeFile', () => {
|
|
198
|
+
test('a directory on the way that is already gone: nothing is there, so success, no sudo', async () => {
|
|
199
|
+
const host = fakeSudoHost();
|
|
200
|
+
await host.runner.removeFile('/opt/example/gone/a.conf');
|
|
201
|
+
expect(host.sudoCalls).toEqual([]);
|
|
202
|
+
expect(host.logs).toEqual([]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('under a prefix: `rm -f --` through sudo', async () => {
|
|
206
|
+
const host = fakeSudoHost();
|
|
207
|
+
host.fake.files.set(CONF, { bytes: bytes('x'), gid: 0, kind: 'file', mode: 0o644, uid: 0 });
|
|
208
|
+
await host.runner.removeFile(CONF);
|
|
209
|
+
expect(host.privileged()).toEqual([[RM, '-f', '--', CONF]]);
|
|
210
|
+
expect(host.fake.files.has(CONF)).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('nothing there is success with no privileged call and no log line', async () => {
|
|
214
|
+
const host = fakeSudoHost();
|
|
215
|
+
await host.runner.removeFile(CONF);
|
|
216
|
+
expect(host.sudoCalls).toEqual([]);
|
|
217
|
+
expect(host.logs).toEqual([]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('a directory is refused, not removed', async () => {
|
|
221
|
+
const host = fakeSudoHost();
|
|
222
|
+
await expect(host.runner.removeFile('/opt/example/app')).rejects.toThrow('is a directory');
|
|
223
|
+
expect(host.sudoCalls).toEqual([]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('outside every prefix it is the operator removing their own file', async () => {
|
|
227
|
+
const host = fakeSudoHost();
|
|
228
|
+
const path = '/Users/someone/Library/LaunchAgents/a.plist';
|
|
229
|
+
host.fake.files.set(path, {
|
|
230
|
+
bytes: bytes('x'),
|
|
231
|
+
gid: 20,
|
|
232
|
+
kind: 'file',
|
|
233
|
+
mode: 0o644,
|
|
234
|
+
uid: OPERATOR,
|
|
235
|
+
});
|
|
236
|
+
await host.runner.removeFile(path);
|
|
237
|
+
expect(host.fake.files.has(path)).toBe(false);
|
|
238
|
+
expect(host.sudoCalls).toEqual([]);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sudoRunner over the fake host (fake-sudo.ts): which calls reach sudo, in what exact argv, what
|
|
3
|
+
* is logged, and what is refused before sudo is ever asked. No real sudo runs anywhere.
|
|
4
|
+
* File writes and removes are in sudo-runner-files.test.ts.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { OPERATOR, PREFIXES, fakeSudoHost } from './fake-sudo.ts';
|
|
8
|
+
import { LAUNCHCTL } from './launchctl.ts';
|
|
9
|
+
import { RM, SUDO } from './sudo-allowlist.ts';
|
|
10
|
+
import { SudoRefusedError, makeSudoRunner, sudoRunner } from './sudo-runner.ts';
|
|
11
|
+
|
|
12
|
+
const PLIST = '/Library/LaunchDaemons/com.example.a.plist';
|
|
13
|
+
const plistBytes = new TextEncoder().encode('<key>Label</key><string>com.example.a</string>');
|
|
14
|
+
|
|
15
|
+
describe('what it claims', () => {
|
|
16
|
+
test('privileged, as the operator: it elevates calls, it is not root', () => {
|
|
17
|
+
const { runner } = fakeSudoHost();
|
|
18
|
+
expect(runner.privileged).toBe(true);
|
|
19
|
+
expect(runner.effectiveUid()).toBe(OPERATOR);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('prefixes are required and checked when it is built', () => {
|
|
23
|
+
expect(() => sudoRunner({ prefixes: [] })).toThrow('no prefixes');
|
|
24
|
+
expect(() => sudoRunner({ prefixes: ['/'] })).toThrow('every path');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('exec', () => {
|
|
29
|
+
test('a system bootstrap runs as `sudo -n --`, logged once, argv only', async () => {
|
|
30
|
+
const host = fakeSudoHost();
|
|
31
|
+
host.fake.files.set(PLIST, { bytes: plistBytes, gid: 0, kind: 'file', mode: 0o644, uid: 0 });
|
|
32
|
+
const result = await host.runner.exec([LAUNCHCTL, 'bootstrap', 'system', PLIST]);
|
|
33
|
+
expect(result.exitCode).toBe(0);
|
|
34
|
+
expect(host.sudoCalls).toEqual([[SUDO, '-n', '--', LAUNCHCTL, 'bootstrap', 'system', PLIST]]);
|
|
35
|
+
expect(host.logs).toEqual([
|
|
36
|
+
`homeflare/launchd sudo -n ${JSON.stringify([LAUNCHCTL, 'bootstrap', 'system', PLIST])}`,
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('bootstrap of a plist that is not a plain file is refused before sudo', async () => {
|
|
41
|
+
const host = fakeSudoHost();
|
|
42
|
+
const missing = host.runner.exec([LAUNCHCTL, 'bootstrap', 'system', PLIST]);
|
|
43
|
+
// ★ The host checks throw the same class as the allowlist: nothing ran as root.
|
|
44
|
+
await expect(missing).rejects.toBeInstanceOf(SudoRefusedError);
|
|
45
|
+
await expect(missing).rejects.toThrow('is missing, not a file');
|
|
46
|
+
host.fake.files.set(PLIST, { bytes: plistBytes, gid: 0, kind: 'symlink', mode: 0o755, uid: 0 });
|
|
47
|
+
await expect(host.runner.exec([LAUNCHCTL, 'bootstrap', 'system', PLIST])).rejects.toThrow(
|
|
48
|
+
'is a symlink',
|
|
49
|
+
);
|
|
50
|
+
expect(host.sudoCalls).toEqual([]);
|
|
51
|
+
expect(host.logs).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('reads stay unelevated: no sudo, no log line', async () => {
|
|
55
|
+
const host = fakeSudoHost();
|
|
56
|
+
const result = await host.runner.exec([LAUNCHCTL, 'print', 'system/com.example.a']);
|
|
57
|
+
expect(result.exitCode).toBe(113);
|
|
58
|
+
await host.runner.exec([LAUNCHCTL, 'print-disabled', 'system']);
|
|
59
|
+
expect(host.sudoCalls).toEqual([]);
|
|
60
|
+
expect(host.logs).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test.each([
|
|
64
|
+
['a bare-domain bootout', [LAUNCHCTL, 'bootout', 'system']],
|
|
65
|
+
["another user's gui domain", [LAUNCHCTL, 'bootout', 'gui/502/com.example.a']],
|
|
66
|
+
['a sudo argv', [SUDO, '-n', RM, '-rf', '/']],
|
|
67
|
+
])('%s is refused before sudo is asked', async (_name, argv) => {
|
|
68
|
+
const host = fakeSudoHost();
|
|
69
|
+
await expect(host.runner.exec(argv)).rejects.toBeInstanceOf(SudoRefusedError);
|
|
70
|
+
expect(host.sudoCalls).toEqual([]);
|
|
71
|
+
expect(host.logs).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a password-required sudo fails at once, saying what to do', async () => {
|
|
75
|
+
const host = fakeSudoHost();
|
|
76
|
+
host.state.sudo = 'password';
|
|
77
|
+
const run = host.runner.exec([LAUNCHCTL, 'bootout', 'system/com.example.a']);
|
|
78
|
+
await expect(run).rejects.toBeInstanceOf(SudoRefusedError);
|
|
79
|
+
await expect(run).rejects.toThrow('a password is required');
|
|
80
|
+
await expect(run).rejects.toThrow('sudo -v');
|
|
81
|
+
expect(host.sudoCalls).toHaveLength(1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test.each(['denied', 'not-in-sudoers', 'not-on-host'] as const)(
|
|
85
|
+
'a sudoers refusal (%s) is named as one',
|
|
86
|
+
async (refusal) => {
|
|
87
|
+
const host = fakeSudoHost();
|
|
88
|
+
host.state.sudo = refusal;
|
|
89
|
+
const run = host.runner.exec([LAUNCHCTL, 'bootout', 'system/com.example.a']);
|
|
90
|
+
await expect(run).rejects.toBeInstanceOf(SudoRefusedError);
|
|
91
|
+
await expect(run).rejects.toThrow('sudoers does not let');
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
test("launchctl's own failure comes back as a result, for the provider to judge", async () => {
|
|
96
|
+
const host = fakeSudoHost();
|
|
97
|
+
const result = await host.runner.exec([LAUNCHCTL, 'bootout', 'system/com.example.a']);
|
|
98
|
+
expect(result.exitCode).toBe(3);
|
|
99
|
+
expect(host.sudoCalls).toHaveLength(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a launchctl error that says "not allowed" is still launchctl\'s, not sudo\'s', async () => {
|
|
103
|
+
const host = fakeSudoHost();
|
|
104
|
+
host.fake.files.set(PLIST, { bytes: plistBytes, gid: 0, kind: 'file', mode: 0o644, uid: 0 });
|
|
105
|
+
// ⚠️ Exit 1 like sudo's own failures; only sudo's message forms may turn it into a refusal.
|
|
106
|
+
host.fake.state.bootstrapFailure = {
|
|
107
|
+
exitCode: 1,
|
|
108
|
+
stderr: 'Bootstrap failed: 1: service is not allowed to execute in this domain',
|
|
109
|
+
stdout: '',
|
|
110
|
+
};
|
|
111
|
+
const result = await host.runner.exec([LAUNCHCTL, 'bootstrap', 'system', PLIST]);
|
|
112
|
+
expect(result.exitCode).toBe(1);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('makeSudoRunner validates prefixes too', () => {
|
|
117
|
+
const { fake } = fakeSudoHost();
|
|
118
|
+
const deps = {
|
|
119
|
+
base: fake.runner,
|
|
120
|
+
groups: async () => [],
|
|
121
|
+
log: () => undefined,
|
|
122
|
+
stage: async () => ({ dispose: async () => undefined, path: '' }),
|
|
123
|
+
};
|
|
124
|
+
expect(() => makeSudoRunner([], deps)).toThrow('no prefixes');
|
|
125
|
+
expect(() => makeSudoRunner(PREFIXES, deps)).not.toThrow();
|
|
126
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sudoRunner(): the kit's one elevating HostRunner. The deploy runs as the operator, and only the
|
|
3
|
+
* calls that need root go through `sudo -n`, in the exact shapes sudo-allowlist.ts names:
|
|
4
|
+
*
|
|
5
|
+
* - `launchctl bootstrap | bootout | kickstart` in the system domain;
|
|
6
|
+
* - a file under a prefix the stack declared: staged in a private temp file as the operator, then
|
|
7
|
+
* `sudo -n install -S -m <mode> [-o <uid>] [-g <gid>] <staged> <path>`; removed with
|
|
8
|
+
* `sudo -n rm -f -- <path>`.
|
|
9
|
+
*
|
|
10
|
+
* Everything else (reads, lookups, the operator's own files and gui domain) goes to localRunner().
|
|
11
|
+
*
|
|
12
|
+
* ★ DECIDED 2026-09-21. A host stack deploys as the operator rather than as root, so a provider bug
|
|
13
|
+
* or a wrong declaration can do no more than this list. It is an explicit opt-in,
|
|
14
|
+
* `launchdProviders(sudoRunner({ prefixes }))`, and nothing ever falls back to it: localRunner()
|
|
15
|
+
* stays the default and never elevates.
|
|
16
|
+
* ⛔ `sudo -n`, NEVER A PROMPT. A deploy that stops mid-apply to ask for a password is the failure
|
|
17
|
+
* "no silent sudo" exists to prevent. With -n, sudo fails at once, and that failure becomes an
|
|
18
|
+
* error saying what to do.
|
|
19
|
+
* ⛔ EVERY PRIVILEGED ARGV IS LOGGED BEFORE IT RUNS, and it is only ever an argv: a file's bytes
|
|
20
|
+
* travel through the staged file, never through argv, so a log line cannot carry content.
|
|
21
|
+
* ★ `install -S`: install(1) on macOS 27.2 (read 2026-09-21) always writes a temp file in the
|
|
22
|
+
* target directory and renames it, so the file is never torn, and -S adds the fsync that
|
|
23
|
+
* localRunner's `handle.sync()` does.
|
|
24
|
+
* ⚠️ REASONED, NOT MEASURED: sudo's failure text (sudo-said.ts has the strings and their sources).
|
|
25
|
+
* ⛔ EVERY CHECK BEFORE SUDO ALSO RUNS AT PLAN TIME (`checkWrite`, 2026-09-21): the mode, the
|
|
26
|
+
* directories from the prefix down, the read-back. Reads only, as the operator — never sudo.
|
|
27
|
+
*/
|
|
28
|
+
import { type LocalRunnerOptions, localRunner } from './local-runner.ts';
|
|
29
|
+
import type { ExecResult, HostRunner, WriteOptions } from './runner.ts';
|
|
30
|
+
import {
|
|
31
|
+
INSTALL,
|
|
32
|
+
RM,
|
|
33
|
+
SUDO,
|
|
34
|
+
SudoRefusedError,
|
|
35
|
+
checkPrefixes,
|
|
36
|
+
octalMode,
|
|
37
|
+
prefixOf,
|
|
38
|
+
privilegedProblem,
|
|
39
|
+
routeExec,
|
|
40
|
+
} from './sudo-allowlist.ts';
|
|
41
|
+
import { assertInstallable, assertPlainPath, operatorGroups } from './sudo-guard.ts';
|
|
42
|
+
import { sudoRefusal, undeclared } from './sudo-said.ts';
|
|
43
|
+
import { type Staged, stageFile } from './sudo-stage.ts';
|
|
44
|
+
|
|
45
|
+
export { SudoRefusedError };
|
|
46
|
+
|
|
47
|
+
export type SudoRunnerOptions = LocalRunnerOptions & {
|
|
48
|
+
/**
|
|
49
|
+
* The directories root may install into and remove from: `/Library/LaunchDaemons` for system
|
|
50
|
+
* jobs (bootstrap and bootout are refused without it), plus wherever the stack's root-owned
|
|
51
|
+
* HostFiles live. ⛔ Required: absolute, normalised, never `/`; and at every privileged call a
|
|
52
|
+
* real directory that root owns and only root may write (sudo-guard.ts). Outside every prefix a
|
|
53
|
+
* file is written as the deploying user, or refused when that needs root.
|
|
54
|
+
*/
|
|
55
|
+
readonly prefixes: readonly string[];
|
|
56
|
+
/** Receives one line per privileged argv, before it runs. @default a line on stderr */
|
|
57
|
+
readonly log?: (line: string) => void;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** What a sudo runner is built from. ★ Split out so the tests swap the host, never the logic. */
|
|
61
|
+
export type SudoDeps = {
|
|
62
|
+
/** Runs everything, including `sudo` itself. localRunner() for the real one. */
|
|
63
|
+
readonly base: HostRunner;
|
|
64
|
+
readonly stage: (bytes: Uint8Array) => Promise<Staged>;
|
|
65
|
+
/** The deploying user's groups, for the read-back check; `undefined` when unknown. */
|
|
66
|
+
readonly groups: () => Promise<readonly number[] | undefined>;
|
|
67
|
+
readonly log: (line: string) => void;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const makeSudoRunner = (prefixList: readonly string[], deps: SudoDeps): HostRunner => {
|
|
71
|
+
const prefixes = checkPrefixes(prefixList);
|
|
72
|
+
const { base } = deps;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* ⛔ THE ONE PLACE `sudo` IS SPAWNED: allowlist, then any host check (`before`), then the log
|
|
76
|
+
* line, then `sudo -n -- argv`.
|
|
77
|
+
*/
|
|
78
|
+
const elevate = async (
|
|
79
|
+
argv: readonly string[],
|
|
80
|
+
{ before, staged }: { before?: () => Promise<unknown>; staged?: string } = {},
|
|
81
|
+
): Promise<ExecResult> => {
|
|
82
|
+
const shown = JSON.stringify(argv);
|
|
83
|
+
const problem = privilegedProblem(argv, {
|
|
84
|
+
prefixes,
|
|
85
|
+
...(staged === undefined ? {} : { staged }),
|
|
86
|
+
});
|
|
87
|
+
if (problem !== undefined)
|
|
88
|
+
throw new SudoRefusedError(`sudoRunner refused ${shown}: ${problem}`);
|
|
89
|
+
await before?.();
|
|
90
|
+
deps.log(`homeflare/launchd sudo -n ${shown}`);
|
|
91
|
+
const result = await base.exec([SUDO, '-n', '--', ...argv]);
|
|
92
|
+
const refused = sudoRefusal(shown, result);
|
|
93
|
+
if (refused !== undefined) throw refused;
|
|
94
|
+
return result;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Every refusal a write can meet before sudo is asked — as the deploying user, reading only — so
|
|
99
|
+
* the same checks run at plan time (`checkWrite`) and at the write. `undefined` outside every
|
|
100
|
+
* prefix, where the operator writes; else the prefix and the mode as `install -m` takes it.
|
|
101
|
+
*/
|
|
102
|
+
const vetWrite = async (path: string, options: WriteOptions) => {
|
|
103
|
+
const prefix = prefixOf(path, prefixes);
|
|
104
|
+
const operator = base.effectiveUid();
|
|
105
|
+
if (prefix === undefined) {
|
|
106
|
+
if (options.uid !== undefined && options.uid !== operator && operator !== 0) {
|
|
107
|
+
throw new SudoRefusedError(
|
|
108
|
+
`sudoRunner ${path}: owner ${String(options.uid)} needs root, and the path is under no ` +
|
|
109
|
+
`declared prefix (${prefixes.join(', ')}). Declare its directory, or deploy as root. ` +
|
|
110
|
+
'Nothing was written.',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
const mode = octalMode(options.mode);
|
|
116
|
+
if (mode === undefined) throw new SudoRefusedError(`sudoRunner ${path}: mode must be 0–0o7777`);
|
|
117
|
+
await assertInstallable(base, prefix, path, options, await deps.groups());
|
|
118
|
+
return { mode, prefix };
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** A privileged file operation: anything but exit 0 is an error. ⚠️ stderr names paths only. */
|
|
122
|
+
const mustSucceed = async (argv: readonly string[], staged?: string): Promise<void> => {
|
|
123
|
+
const result = await elevate(argv, staged === undefined ? {} : { staged });
|
|
124
|
+
if (result.exitCode !== 0) {
|
|
125
|
+
const stderr = result.stderr.trim().slice(0, 300);
|
|
126
|
+
throw new Error(`sudo -n ${JSON.stringify(argv)} -> ${String(result.exitCode)}: ${stderr}`);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
// ★ The plan-time half of vetWrite: the providers' diffs call it, and it never elevates.
|
|
132
|
+
checkWrite: async (path, options) => {
|
|
133
|
+
await vetWrite(path, options);
|
|
134
|
+
},
|
|
135
|
+
effectiveUid: () => base.effectiveUid(),
|
|
136
|
+
exec: async (argv) => {
|
|
137
|
+
const route = routeExec(argv, base.effectiveUid());
|
|
138
|
+
if ('refuse' in route) throw new SudoRefusedError(`sudoRunner: ${route.refuse}`);
|
|
139
|
+
if (route.as === 'operator') return base.exec(argv);
|
|
140
|
+
// ⛔ bootstrap makes root read the plist: it must be a plain file exactly where argv says.
|
|
141
|
+
// The allowlist has already put it under a prefix by the time `before` runs.
|
|
142
|
+
const plist = argv[1] === 'bootstrap' ? argv[3] : undefined;
|
|
143
|
+
const prefix = plist === undefined ? undefined : prefixOf(plist, prefixes);
|
|
144
|
+
return elevate(argv, {
|
|
145
|
+
before: async () => {
|
|
146
|
+
if (plist !== undefined && prefix !== undefined) {
|
|
147
|
+
await assertPlainPath(base, prefix, plist, 'file');
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
lookupGroup: (nameOrId) => base.lookupGroup(nameOrId),
|
|
153
|
+
lookupUser: (nameOrId) => base.lookupUser(nameOrId),
|
|
154
|
+
// ★ True, so the providers let a system-domain job through to this runner (canActAsRoot).
|
|
155
|
+
privileged: true,
|
|
156
|
+
readFile: (path) => base.readFile(path),
|
|
157
|
+
removeFile: async (path) => {
|
|
158
|
+
const prefix = prefixOf(path, prefixes);
|
|
159
|
+
if (prefix === undefined) return base.removeFile(path).catch(undeclared(path, prefixes));
|
|
160
|
+
/**
|
|
161
|
+
* ★ Nothing there is success without a privileged call, so an idempotent delete logs nothing.
|
|
162
|
+
* ⚠️ lstat the path FIRST: a directory on the way that is already gone means nothing is there
|
|
163
|
+
* (HostRunner.removeFile is idempotent, and localRunner treats ENOENT/ENOTDIR so), where the
|
|
164
|
+
* guard alone would refuse it as a missing directory.
|
|
165
|
+
*/
|
|
166
|
+
if ((await base.stat(path)) === undefined) return;
|
|
167
|
+
await assertPlainPath(base, prefix, path, 'file');
|
|
168
|
+
await mustSucceed([RM, '-f', '--', path]);
|
|
169
|
+
},
|
|
170
|
+
sleep: (ms) => base.sleep(ms),
|
|
171
|
+
stat: (path) => base.stat(path),
|
|
172
|
+
writeFileAtomic: async (path, bytes, options) => {
|
|
173
|
+
const vetted = await vetWrite(path, options);
|
|
174
|
+
if (vetted === undefined) {
|
|
175
|
+
return base.writeFileAtomic(path, bytes, options).catch(undeclared(path, prefixes));
|
|
176
|
+
}
|
|
177
|
+
const { mode } = vetted;
|
|
178
|
+
const staged = await deps.stage(bytes);
|
|
179
|
+
try {
|
|
180
|
+
await mustSucceed(
|
|
181
|
+
[
|
|
182
|
+
INSTALL,
|
|
183
|
+
'-S',
|
|
184
|
+
'-m',
|
|
185
|
+
mode,
|
|
186
|
+
...(options.uid === undefined ? [] : ['-o', String(options.uid)]),
|
|
187
|
+
...(options.gid === undefined ? [] : ['-g', String(options.gid)]),
|
|
188
|
+
staged.path,
|
|
189
|
+
path,
|
|
190
|
+
],
|
|
191
|
+
staged.path,
|
|
192
|
+
);
|
|
193
|
+
} finally {
|
|
194
|
+
await staged.dispose();
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The real sudo runner: localRunner() for everything, `/usr/bin/sudo -n` for the allowlist.
|
|
202
|
+
* Pass it explicitly: `launchdProviders(sudoRunner({ prefixes: ['/Library/LaunchDaemons'] }))`.
|
|
203
|
+
*/
|
|
204
|
+
export const sudoRunner = (options: SudoRunnerOptions): HostRunner => {
|
|
205
|
+
const base = localRunner(options);
|
|
206
|
+
return makeSudoRunner(options.prefixes, {
|
|
207
|
+
base,
|
|
208
|
+
groups: () => operatorGroups(base),
|
|
209
|
+
log:
|
|
210
|
+
options.log ??
|
|
211
|
+
((line) => {
|
|
212
|
+
process.stderr.write(`${line}\n`);
|
|
213
|
+
}),
|
|
214
|
+
stage: stageFile,
|
|
215
|
+
});
|
|
216
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What sudo's own failure means, for sudo-runner.ts: which exit-1 answers are sudo refusing to run
|
|
3
|
+
* the command (so NOTHING ran as root), and what an EACCES outside every prefix most likely means.
|
|
4
|
+
* Split out of sudo-runner.ts, which keeps only the runner.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ REASONED, NOT MEASURED: sudo's failure text. The kit never runs sudo. The strings below follow
|
|
7
|
+
* sudoers(5) for sudo 1.9.17p2 on macOS 27.2 ("a password is required": -n was given but a
|
|
8
|
+
* password was needed) and sudo(8) (sudo exits 1 when it fails itself).
|
|
9
|
+
*/
|
|
10
|
+
import { errnoCode } from './local-runner.ts';
|
|
11
|
+
import type { ExecResult } from './runner.ts';
|
|
12
|
+
import { SudoRefusedError } from './sudo-allowlist.ts';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* ⚠️ ANCHORED TO sudo'S OWN MESSAGE FORMS (`sudo: …` lines, `Sorry, user …`), so a launchctl or
|
|
16
|
+
* install error that happens to say "not allowed" is never reported as "the command did not run".
|
|
17
|
+
* The refusals are the three a sudoers denial prints: the command not allowed, the user not in
|
|
18
|
+
* sudoers, and the user not allowed on this host (`<user> is not allowed to run sudo on <host>.`).
|
|
19
|
+
* REASONED from sudo 1.9's sudoers plugin, like the rest (header).
|
|
20
|
+
*/
|
|
21
|
+
const PASSWORD =
|
|
22
|
+
/^sudo: (?:a password is required|a terminal is required|sorry, you must have a tty)/m;
|
|
23
|
+
const NOT_ALLOWED =
|
|
24
|
+
/^(?:Sorry, user \S+ (?:is not allowed to execute|may not run sudo)|\S+ is not (?:in the sudoers file|allowed to run sudo on ))/m;
|
|
25
|
+
|
|
26
|
+
/** ★ An EACCES outside every prefix is almost always a directory the stack forgot to declare. */
|
|
27
|
+
export const undeclared =
|
|
28
|
+
(path: string, prefixes: readonly string[]) =>
|
|
29
|
+
(cause: unknown): never => {
|
|
30
|
+
const code = errnoCode(cause);
|
|
31
|
+
if (code !== 'EACCES' && code !== 'EPERM') throw cause;
|
|
32
|
+
throw new Error(
|
|
33
|
+
`sudoRunner ${path}: ${code} as the deploying user, and the path is under no declared prefix ` +
|
|
34
|
+
`(${prefixes.join(', ')}). If root owns its directory, declare that directory as a prefix.`,
|
|
35
|
+
{ cause },
|
|
36
|
+
);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The refusal sudo itself answered for `shown` (the argv as JSON), or `undefined` when the command
|
|
41
|
+
* ran — whatever its own exit code. ★ `SudoRefusedError` always means nothing ran as root.
|
|
42
|
+
*/
|
|
43
|
+
export const sudoRefusal = (shown: string, result: ExecResult): SudoRefusedError | undefined => {
|
|
44
|
+
if (result.exitCode !== 1) return undefined;
|
|
45
|
+
if (PASSWORD.test(result.stderr)) {
|
|
46
|
+
return new SudoRefusedError(
|
|
47
|
+
`sudo -n ${shown}: a password is required, and this runner never prompts. Run \`sudo -v\` ` +
|
|
48
|
+
'in the deploying terminal just before the deploy, or grant exactly these commands ' +
|
|
49
|
+
'NOPASSWD (docs/launchd-sudo.md). The command did not run.',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (NOT_ALLOWED.test(result.stderr)) {
|
|
53
|
+
return new SudoRefusedError(
|
|
54
|
+
`sudo -n ${shown}: sudoers does not let the deploying user run this. The command did not run.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where sudo-runner.ts puts a file's bytes before `install` copies them into place as root.
|
|
3
|
+
*
|
|
4
|
+
* ★ WHY STAGE AT ALL. The deploying user cannot write inside a root-owned directory, and the bytes
|
|
5
|
+
* must not travel through argv (they would reach the log and `ps`). So they are written, as the
|
|
6
|
+
* deploying user, to a private file, and `install` copies that one file.
|
|
7
|
+
* ⛔ A 0700 DIRECTORY, A 0600 FILE, O_EXCL: between the write and the install, nobody but the
|
|
8
|
+
* deploying user and root can read the bytes or swap the file.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
/** A file staged for `install`, and how to remove it again. */
|
|
15
|
+
export type Staged = { readonly path: string; readonly dispose: () => Promise<void> };
|
|
16
|
+
|
|
17
|
+
export const stageFile = async (bytes: Uint8Array): Promise<Staged> => {
|
|
18
|
+
const dir = await mkdtemp(join(tmpdir(), 'hf-sudo-'));
|
|
19
|
+
const dispose = () => rm(dir, { force: true, recursive: true });
|
|
20
|
+
try {
|
|
21
|
+
const path = join(dir, 'staged');
|
|
22
|
+
await writeFile(path, bytes, { flag: 'wx', mode: 0o600 });
|
|
23
|
+
return { dispose, path };
|
|
24
|
+
} catch (cause) {
|
|
25
|
+
await dispose();
|
|
26
|
+
throw cause;
|
|
27
|
+
}
|
|
28
|
+
};
|