@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
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
fileProblems,
|
|
16
16
|
} from './host-file-form.ts';
|
|
17
17
|
import { sha256Hex } from './job-form.ts';
|
|
18
|
-
import { type HostRunner, canActAsRoot } from './runner.ts';
|
|
18
|
+
import { type HostRunner, type WriteOptions, canActAsRoot } from './runner.ts';
|
|
19
19
|
|
|
20
20
|
type Desired = {
|
|
21
21
|
readonly bytes: Uint8Array;
|
|
@@ -72,29 +72,55 @@ export const readFileAttributes = async (
|
|
|
72
72
|
};
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
+
const writeOptionsOf = (want: Desired): WriteOptions => ({
|
|
76
|
+
mode: want.mode,
|
|
77
|
+
...(want.uid === undefined ? {} : { uid: want.uid }),
|
|
78
|
+
...(want.gid === undefined ? {} : { gid: want.gid }),
|
|
79
|
+
});
|
|
80
|
+
|
|
75
81
|
const matches = (live: HostFileAttributes, want: Desired): boolean =>
|
|
76
82
|
live.sha256 === want.sha256 &&
|
|
77
83
|
live.mode === want.mode &&
|
|
78
84
|
(want.uid === undefined || live.uid === want.uid) &&
|
|
79
85
|
(want.gid === undefined || live.gid === want.gid);
|
|
80
86
|
|
|
87
|
+
/**
|
|
88
|
+
* ★ A PLAN THAT WILL WRITE ASKS THE RUNNER FIRST (`checkWrite`, when it has one): sudoRunner's
|
|
89
|
+
* refusals — a setuid or group-writable root file, a directory under a prefix another user may
|
|
90
|
+
* change — then fail the plan, before any resource is applied, instead of halfway through it.
|
|
91
|
+
*/
|
|
81
92
|
export const diffFile = async (
|
|
82
93
|
runner: HostRunner,
|
|
83
94
|
news: HostFileProps,
|
|
84
95
|
output: HostFileAttributes,
|
|
85
96
|
): Promise<Diff> => {
|
|
86
|
-
// ★ Create-before-delete: two paths can hold two files at once, so nothing forces deleteFirst.
|
|
87
|
-
if (news.path !== output.path) return { action: 'replace' };
|
|
88
97
|
const want = await desiredFile(runner, news);
|
|
89
|
-
|
|
98
|
+
const writes = async (diff: Diff): Promise<Diff> => {
|
|
99
|
+
await runner.checkWrite?.(news.path, writeOptionsOf(want));
|
|
100
|
+
return diff;
|
|
101
|
+
};
|
|
102
|
+
// ★ Create-before-delete: two paths can hold two files at once, so nothing forces deleteFirst.
|
|
103
|
+
if (news.path !== output.path) return writes({ action: 'replace' });
|
|
104
|
+
if (want.sha256 !== output.sha256) return writes({ action: 'update' });
|
|
90
105
|
const live = await readFileAttributes(runner, news.path);
|
|
91
|
-
return live !== undefined && matches(live, want)
|
|
106
|
+
return live !== undefined && matches(live, want)
|
|
107
|
+
? { action: 'noop' }
|
|
108
|
+
: writes({ action: 'update' });
|
|
92
109
|
};
|
|
93
110
|
|
|
111
|
+
/**
|
|
112
|
+
* `adopt` is whether adoption is on AND this apply is a create or an unfinished generation of our
|
|
113
|
+
* own (ownership/adopt.ts adoptsAtApply). It lets such a generation take over a file already at the
|
|
114
|
+
* path — the takeover the plan's probe would have allowed, had it run. ⛔ A move onto an occupied
|
|
115
|
+
* path stays refused, both ways the engine drives one: an `update` across the move (`moved`,
|
|
116
|
+
* below), and a fresh replace's new generation, which the caller never marks adoptable (its
|
|
117
|
+
* `output` is undefined too, so this function cannot tell it from a create by itself).
|
|
118
|
+
*/
|
|
94
119
|
export const reconcileFile = async (
|
|
95
120
|
runner: HostRunner,
|
|
96
121
|
props: HostFileProps,
|
|
97
122
|
output?: HostFileAttributes,
|
|
123
|
+
adopt = false,
|
|
98
124
|
): Promise<HostFileAttributes> => {
|
|
99
125
|
const want = await desiredFile(runner, props);
|
|
100
126
|
// ⛔ NO SILENT SUDO: handing a file to another user is root's call (chown(2)), so say so up front.
|
|
@@ -102,7 +128,8 @@ export const reconcileFile = async (
|
|
|
102
128
|
throw refuse(
|
|
103
129
|
props.path,
|
|
104
130
|
`owner ${String(props.owner)} is not the deploying user; only root may chown. Deploy as root ` +
|
|
105
|
-
'or through a privileged HostRunner (
|
|
131
|
+
'or through a privileged HostRunner (sudoRunner() with this path under a prefix). This ' +
|
|
132
|
+
'provider never calls sudo itself.',
|
|
106
133
|
);
|
|
107
134
|
}
|
|
108
135
|
const stat = await runner.stat(props.path);
|
|
@@ -119,11 +146,14 @@ export const reconcileFile = async (
|
|
|
119
146
|
const prior = moved ? undefined : output;
|
|
120
147
|
/**
|
|
121
148
|
* ⛔ A FILE THIS RESOURCE DOES NOT OWN IS NEVER OVERWRITTEN. With no prior state for this path,
|
|
122
|
-
* the engine's adoption probe has already refused anything it found — except
|
|
123
|
-
* a replace,
|
|
124
|
-
*
|
|
149
|
+
* the engine's adoption probe has already refused anything it found — except where it never
|
|
150
|
+
* looked: the new path of a replace, and a create whose props still held an Output at plan
|
|
151
|
+
* time. A path typo there would otherwise overwrite, say, a system file that a fresh
|
|
152
|
+
* declaration of the same path would have been refused as `Unowned`. ★ With adoption on, a
|
|
153
|
+
* create takes it over, as the probe would have let it (decided 2026-09-21).
|
|
125
154
|
*/
|
|
126
|
-
|
|
155
|
+
const takeOver = adopt && output === undefined;
|
|
156
|
+
if (prior === undefined && before !== undefined && !matches(before, want) && !takeOver) {
|
|
127
157
|
throw refuse(
|
|
128
158
|
props.path,
|
|
129
159
|
'already exists and is not this resource. Remove it, or declare it as a new resource and ' +
|
|
@@ -131,11 +161,7 @@ export const reconcileFile = async (
|
|
|
131
161
|
);
|
|
132
162
|
}
|
|
133
163
|
if (before === undefined || !matches(before, want)) {
|
|
134
|
-
await runner.writeFileAtomic(props.path, want.bytes,
|
|
135
|
-
mode: want.mode,
|
|
136
|
-
...(want.uid === undefined ? {} : { uid: want.uid }),
|
|
137
|
-
...(want.gid === undefined ? {} : { gid: want.gid }),
|
|
138
|
-
});
|
|
164
|
+
await runner.writeFileAtomic(props.path, want.bytes, writeOptionsOf(want));
|
|
139
165
|
}
|
|
140
166
|
// ⚠️ READ BACK, never echo the declaration: a umask, an ACL or a runner that ignored `uid` shows
|
|
141
167
|
// up here as a refusal instead of as a forever-`update`.
|
package/src/launchd/host-file.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* - delete — removes the file.
|
|
10
10
|
*
|
|
11
11
|
* ⛔ NEVER A SECRET — see host-file-form.ts. Secret files stay rendered by openbao-agent.
|
|
12
|
-
* ⚠️ NOTHING IS ADOPTED WITHOUT `--adopt`: a file already at the path reads as `Unowned
|
|
12
|
+
* ⚠️ NOTHING IS ADOPTED WITHOUT `--adopt`: a file already at the path reads as `Unowned`, and where
|
|
13
|
+
* the plan never asked, reconcile refuses it unless adoption is on (docs/ownership.md).
|
|
13
14
|
* ★ IT LIVES IN THE launchd SUBPATH because the two travel together: a Mac host stack declares a
|
|
14
15
|
* daemon's config file and the daemon that reads it, through the same HostRunner.
|
|
15
16
|
*/
|
|
@@ -18,6 +19,8 @@ import { Unowned } from 'alchemy/AdoptPolicy';
|
|
|
18
19
|
import { isResolved } from 'alchemy/Diff';
|
|
19
20
|
import * as Provider from 'alchemy/Provider';
|
|
20
21
|
import * as Effect from 'effect/Effect';
|
|
22
|
+
import { adoptsAtApply } from '../ownership/adopt.ts';
|
|
23
|
+
import { noteUnfinished } from '../ownership/resume.ts';
|
|
21
24
|
import { lift, resolvedString } from './host-effect.ts';
|
|
22
25
|
import type { HostFileAttributes, HostFileProps } from './host-file-form.ts';
|
|
23
26
|
import { deleteFile, diffFile, readFileAttributes, reconcileFile } from './host-file-lifecycle.ts';
|
|
@@ -44,8 +47,9 @@ export const HostFileProvider = () =>
|
|
|
44
47
|
return output === undefined ? Unowned(found) : found;
|
|
45
48
|
}),
|
|
46
49
|
|
|
47
|
-
diff: ({ news, output }) => {
|
|
48
|
-
|
|
50
|
+
diff: ({ instanceId, news, output }) => {
|
|
51
|
+
// ★ An unfinished generation of our own: `--adopt` may resume it (ownership/resume.ts).
|
|
52
|
+
if (output === undefined) return noteUnfinished(instanceId);
|
|
49
53
|
if (isResolved(news)) return lift(() => diffFile(runner, news, output));
|
|
50
54
|
// ⛔ A new path is a replace even while `content` is unresolved (an Output templated in):
|
|
51
55
|
// the engine's default would be an update, which writes the new path and never
|
|
@@ -56,7 +60,12 @@ export const HostFileProvider = () =>
|
|
|
56
60
|
);
|
|
57
61
|
},
|
|
58
62
|
|
|
59
|
-
|
|
63
|
+
// ★ `--adopt` reaches the apply too: the probe never ran for a create with an Output prop.
|
|
64
|
+
// ⛔ A create or an unfinished generation — never a fresh replace's, never offered it.
|
|
65
|
+
reconcile: ({ fqn, instanceId, news, output }) =>
|
|
66
|
+
Effect.flatMap(adoptsAtApply({ fqn, instanceId, output }), (adopt) =>
|
|
67
|
+
lift(() => reconcileFile(runner, news, output, adopt)),
|
|
68
|
+
),
|
|
60
69
|
|
|
61
70
|
delete: ({ output }) => lift(() => deleteFile(runner, output)),
|
|
62
71
|
});
|
package/src/launchd/index.ts
CHANGED
|
@@ -24,3 +24,5 @@ export { PlistError, renderPlist } from './plist.ts';
|
|
|
24
24
|
export { launchdProviders } from './providers.ts';
|
|
25
25
|
export type { ExecResult, FileStat, HostRunner, HostUser, WriteOptions } from './runner.ts';
|
|
26
26
|
export { HostRunnerService, canActAsRoot, hostRunnerLayer } from './runner.ts';
|
|
27
|
+
export type { SudoRunnerOptions } from './sudo-runner.ts';
|
|
28
|
+
export { SudoRefusedError, sudoRunner } from './sudo-runner.ts';
|
|
@@ -28,7 +28,9 @@ import {
|
|
|
28
28
|
assertReplaceable,
|
|
29
29
|
assertUnclaimed,
|
|
30
30
|
assertValid,
|
|
31
|
+
checkPlistWrite,
|
|
31
32
|
locate,
|
|
33
|
+
plistWriteOptions,
|
|
32
34
|
preflight,
|
|
33
35
|
refuse,
|
|
34
36
|
} from './job-preflight.ts';
|
|
@@ -94,19 +96,31 @@ export const diffJob = async (
|
|
|
94
96
|
}
|
|
95
97
|
assertValid(news);
|
|
96
98
|
const desired = renderJob(news).sha256;
|
|
99
|
+
// ★ A plan that will write the plist asks the runner first (checkPlistWrite, HostRunner.checkWrite).
|
|
100
|
+
const update = async (): Promise<Diff> => {
|
|
101
|
+
await checkPlistWrite(runner, await locate(runner, news));
|
|
102
|
+
return { action: 'update' };
|
|
103
|
+
};
|
|
97
104
|
// ⛔ BOTH digests: the stored one catches a bootstrap that failed after the write landed, the live
|
|
98
105
|
// one catches a hand edit. Either alone would report `noop` over a job that is not what we say.
|
|
99
|
-
if (desired !== output.plistSha256) return
|
|
106
|
+
if (desired !== output.plistSha256) return update();
|
|
100
107
|
const live = await readJob(runner, news);
|
|
101
|
-
if (live === undefined || !live.loaded || live.plistSha256 !== desired)
|
|
102
|
-
return { action: 'update' };
|
|
108
|
+
if (live === undefined || !live.loaded || live.plistSha256 !== desired) return update();
|
|
103
109
|
return { action: 'noop' };
|
|
104
110
|
};
|
|
105
111
|
|
|
112
|
+
/**
|
|
113
|
+
* `adopt` is whether adoption is on AND this apply is a create or an unfinished generation of our
|
|
114
|
+
* own (ownership/adopt.ts adoptsAtApply): it lets such a generation take over a job already on the
|
|
115
|
+
* host, as the plan's probe would have. A rename onto an occupied label stays refused, as it is at
|
|
116
|
+
* plan time (assertReplaceable) — including a label taken between that plan and a fresh replace's
|
|
117
|
+
* new generation, which the caller never marks adoptable.
|
|
118
|
+
*/
|
|
106
119
|
export const reconcileJob = async (
|
|
107
120
|
runner: HostRunner,
|
|
108
121
|
props: LaunchdJobProps,
|
|
109
122
|
output: LaunchdJobAttributes | undefined,
|
|
123
|
+
adopt = false,
|
|
110
124
|
): Promise<LaunchdJobAttributes> => {
|
|
111
125
|
assertValid(props);
|
|
112
126
|
const { location, status } = await preflight(runner, props);
|
|
@@ -121,8 +135,9 @@ export const reconcileJob = async (
|
|
|
121
135
|
const renamed =
|
|
122
136
|
output !== undefined && (output.label !== props.label || output.domain !== props.domain);
|
|
123
137
|
const prior = renamed ? undefined : output;
|
|
124
|
-
// ⛔ No prior state for THIS identity: whatever is there already belongs to someone else
|
|
125
|
-
|
|
138
|
+
// ⛔ No prior state for THIS identity: whatever is there already belongs to someone else —
|
|
139
|
+
// unless adoption is on for a create, where the probe was skipped (a prop still an Output).
|
|
140
|
+
if (prior === undefined && !(adopt && output === undefined)) {
|
|
126
141
|
await assertUnclaimed(runner, props.label, location, status, rendered.sha256);
|
|
127
142
|
}
|
|
128
143
|
if (renamed) await deleteJob(runner, output);
|
|
@@ -138,18 +153,11 @@ export const reconcileJob = async (
|
|
|
138
153
|
) {
|
|
139
154
|
return attributesOf(props, location, rendered.sha256, status);
|
|
140
155
|
}
|
|
141
|
-
|
|
142
|
-
* ⚠️ launchd refuses a daemon plist that is not root:wheel or is group/world-writable ("bad
|
|
143
|
-
* ownership/permissions"), so the system domain always writes 0644 root:wheel. An agent's plist
|
|
144
|
-
* belongs to its user. REASONED from launchd's documented behaviour, not measured here (no
|
|
145
|
-
* bootstrap was run to write this); the nix-darwin daemons on the reference host are 0644 root.
|
|
146
|
-
*/
|
|
156
|
+
// ⚠️ Owner and mode are launchd's rule, not a choice: see plistWriteOptions (job-preflight.ts).
|
|
147
157
|
await runner.writeFileAtomic(
|
|
148
158
|
location.plistPath,
|
|
149
159
|
rendered.bytes,
|
|
150
|
-
location.domain
|
|
151
|
-
? { gid: 0, mode: 0o644, uid: 0 }
|
|
152
|
-
: { mode: 0o644, uid: location.domain.uid },
|
|
160
|
+
plistWriteOptions(location.domain),
|
|
153
161
|
);
|
|
154
162
|
await bootoutIfLoaded(runner, location.target);
|
|
155
163
|
const failed = async (detail: string): Promise<never> => {
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from './job-form.ts';
|
|
22
22
|
import { identityProblems, jobProblems } from './job-validate.ts';
|
|
23
23
|
import { type ServiceStatus, isDisabled, printService } from './launchctl.ts';
|
|
24
|
-
import { type HostRunner, canActAsRoot } from './runner.ts';
|
|
24
|
+
import { type HostRunner, type WriteOptions, canActAsRoot } from './runner.ts';
|
|
25
25
|
|
|
26
26
|
export type Location = {
|
|
27
27
|
readonly domain: ParsedDomain;
|
|
@@ -31,6 +31,22 @@ export type Location = {
|
|
|
31
31
|
|
|
32
32
|
export type Identity = Pick<LaunchdJobProps, 'label' | 'domain'>;
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* ⚠️ launchd refuses a daemon plist that is not root:wheel or is group/world-writable ("bad
|
|
36
|
+
* ownership/permissions"), so the system domain always writes 0644 root:wheel. An agent's plist
|
|
37
|
+
* belongs to its user. REASONED from launchd's documented behaviour, not measured here (no
|
|
38
|
+
* bootstrap was run to write this); the nix-darwin daemons on the reference host are 0644 root.
|
|
39
|
+
*/
|
|
40
|
+
export const plistWriteOptions = (domain: ParsedDomain): WriteOptions =>
|
|
41
|
+
domain.kind === 'system' ? { gid: 0, mode: 0o644, uid: 0 } : { mode: 0o644, uid: domain.uid };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* PLAN TIME, for a plan that will write the plist: the runner's own pre-write refusals
|
|
45
|
+
* (HostRunner.checkWrite — sudoRunner's directory and mode checks), reading only.
|
|
46
|
+
*/
|
|
47
|
+
export const checkPlistWrite = async (runner: HostRunner, location: Location): Promise<void> =>
|
|
48
|
+
runner.checkWrite?.(location.plistPath, plistWriteOptions(location.domain));
|
|
49
|
+
|
|
34
50
|
export const refuse = (label: string, message: string): Error =>
|
|
35
51
|
new Error(`Launchd.Job ${label}: ${message}`);
|
|
36
52
|
|
|
@@ -61,8 +77,9 @@ export const assertMayWrite = (runner: HostRunner, label: string, domain: Parsed
|
|
|
61
77
|
if (domain.kind === 'system') {
|
|
62
78
|
throw refuse(
|
|
63
79
|
label,
|
|
64
|
-
'the system domain needs root. Run the deploy as root, or
|
|
65
|
-
'
|
|
80
|
+
'the system domain needs root. Run the deploy as root, or pass a deliberately privileged ' +
|
|
81
|
+
'HostRunner: sudoRunner() elevates exactly the calls this needs. This provider never ' +
|
|
82
|
+
'calls sudo itself.',
|
|
66
83
|
);
|
|
67
84
|
}
|
|
68
85
|
if (domain.uid !== runner.effectiveUid()) {
|
|
@@ -149,4 +166,5 @@ export const assertReplaceable = async (
|
|
|
149
166
|
assertMayWrite(runner, old.label, oldDomain);
|
|
150
167
|
const { location, status } = await preflight(runner, next);
|
|
151
168
|
await assertUnclaimed(runner, next.label, location, status, renderedSha256);
|
|
169
|
+
await checkPlistWrite(runner, location);
|
|
152
170
|
};
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
import { jobSecretProblems } from './secret-tripwire.ts';
|
|
16
16
|
|
|
17
17
|
/** ★ Labels become file names: no `/`, no leading dot, nothing a path could re-interpret. */
|
|
18
|
-
const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
|
|
18
|
+
export const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* ⛔ LABELS OTHER TOOLS OWN ARE NEVER DECLARED HERE. nix-darwin (`org.nixos.*`) rewrites its plists
|
package/src/launchd/job.ts
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* ⛔ ENVIRONMENT IS NON-SECRET ONLY — see the ⛔ on LaunchdJobProps.environment.
|
|
19
19
|
* ⛔ NO SILENT SUDO — see assertMayWrite in job-preflight.ts.
|
|
20
20
|
* ⚠️ NOTHING IS ADOPTED WITHOUT `--adopt`. A label already loaded, or a plist already on disk, reads
|
|
21
|
-
* as `Unowned`, so Alchemy refuses to take it over unless asked
|
|
21
|
+
* as `Unowned`, so Alchemy refuses to take it over unless asked — and reconcile, where the plan
|
|
22
|
+
* never asked, honours the same `--adopt` / `adopt(…)` (docs/ownership.md). Labels under `org.nixos.`,
|
|
22
23
|
* `com.apple.` and `homebrew.mxcl.` are refused outright (job-validate.ts) — declare a new label
|
|
23
24
|
* and cut over instead (docs/launchd.md).
|
|
24
25
|
*/
|
|
@@ -27,6 +28,8 @@ import { Unowned } from 'alchemy/AdoptPolicy';
|
|
|
27
28
|
import { isResolved } from 'alchemy/Diff';
|
|
28
29
|
import * as Provider from 'alchemy/Provider';
|
|
29
30
|
import * as Effect from 'effect/Effect';
|
|
31
|
+
import { adoptsAtApply } from '../ownership/adopt.ts';
|
|
32
|
+
import { noteUnfinished } from '../ownership/resume.ts';
|
|
30
33
|
import { lift, resolvedString } from './host-effect.ts';
|
|
31
34
|
import type { LaunchdJobAttributes, LaunchdJobProps } from './job-form.ts';
|
|
32
35
|
import { deleteJob, diffJob, readJob, reconcileJob, replaceDiff } from './job-lifecycle.ts';
|
|
@@ -67,8 +70,9 @@ export const LaunchdJobProvider = () =>
|
|
|
67
70
|
return output === undefined ? Unowned(found) : found;
|
|
68
71
|
}),
|
|
69
72
|
|
|
70
|
-
diff: ({ news, output }) => {
|
|
71
|
-
|
|
73
|
+
diff: ({ instanceId, news, output }) => {
|
|
74
|
+
// ★ An unfinished generation of our own: `--adopt` may resume it (ownership/resume.ts).
|
|
75
|
+
if (output === undefined) return noteUnfinished(instanceId);
|
|
72
76
|
if (isResolved(news)) return lift(() => diffJob(runner, news, output));
|
|
73
77
|
/**
|
|
74
78
|
* ⛔ A RENAME MUST BE SEEN EVEN WHILE OTHER PROPS ARE UNRESOLVED. Returning undefined
|
|
@@ -88,7 +92,12 @@ export const LaunchdJobProvider = () =>
|
|
|
88
92
|
: Effect.succeed(undefined);
|
|
89
93
|
},
|
|
90
94
|
|
|
91
|
-
|
|
95
|
+
// ★ `--adopt` reaches the apply too: the probe never ran for a create with an Output prop.
|
|
96
|
+
// ⛔ A create or an unfinished generation — never a fresh replace's, never offered it.
|
|
97
|
+
reconcile: ({ fqn, instanceId, news, output }) =>
|
|
98
|
+
Effect.flatMap(adoptsAtApply({ fqn, instanceId, output }), (adopt) =>
|
|
99
|
+
lift(() => reconcileJob(runner, news, output, adopt)),
|
|
100
|
+
),
|
|
92
101
|
|
|
93
102
|
delete: ({ output }) => lift(() => deleteJob(runner, output)),
|
|
94
103
|
});
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* the published dist also loads under node (the package's smoke test and the kit's AGENTS.md:
|
|
6
6
|
* consumers run dist/, not src/), and both runtimes implement these modules.
|
|
7
7
|
* ⛔ NO ELEVATION, EVER. `privileged` is false; a system-domain write succeeds only when the whole
|
|
8
|
-
* deploy was started as root. See the ⛔ on HostRunner.privileged for why nothing here calls sudo
|
|
8
|
+
* deploy was started as root. See the ⛔ on HostRunner.privileged for why nothing here calls sudo;
|
|
9
|
+
* the opt-in elevating runner is sudo-runner.ts, which wraps this one.
|
|
9
10
|
* ⛔ NO SHELL. `exec` spawns argv directly, so a label, path or argument can never be re-parsed
|
|
10
11
|
* as shell syntax.
|
|
11
12
|
*/
|
|
@@ -21,13 +22,15 @@ export type LocalRunnerOptions = {
|
|
|
21
22
|
readonly execTimeoutMs?: number;
|
|
22
23
|
};
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
/** The errno code of a thrown fs error (`ENOENT`, `EACCES`, …), if it has one. */
|
|
26
|
+
export const errnoCode = (cause: unknown): string | undefined =>
|
|
25
27
|
typeof cause === 'object' && cause !== null && 'code' in cause
|
|
26
28
|
? String((cause as { code: unknown }).code)
|
|
27
29
|
: undefined;
|
|
28
30
|
|
|
29
31
|
/** ENOENT and ENOTDIR both mean "nothing is at this path". */
|
|
30
|
-
const absent = (cause: unknown): boolean =>
|
|
32
|
+
const absent = (cause: unknown): boolean =>
|
|
33
|
+
errnoCode(cause) === 'ENOENT' || errnoCode(cause) === 'ENOTDIR';
|
|
31
34
|
|
|
32
35
|
const run = (argv: readonly string[], timeoutMs: number): Promise<ExecResult> =>
|
|
33
36
|
new Promise((resolve, reject) => {
|
|
@@ -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. */
|
|
@@ -71,6 +73,13 @@ export interface HostRunner {
|
|
|
71
73
|
* ⚠️ The parent directory must exist; creating it would invent an owner and mode nobody declared.
|
|
72
74
|
*/
|
|
73
75
|
writeFileAtomic(path: string, bytes: Uint8Array, options: WriteOptions): Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* PLAN TIME: refuse, reading only and writing nothing, a write this runner would refuse at
|
|
78
|
+
* apply — so the plan shows it before any resource is applied. Optional: a runner without it
|
|
79
|
+
* refuses at the write. ★ sudoRunner implements it with the checks it makes before `sudo -n`;
|
|
80
|
+
* ⛔ it never elevates, so a plan still never calls sudo.
|
|
81
|
+
*/
|
|
82
|
+
checkWrite?(path: string, options: WriteOptions): Promise<void>;
|
|
74
83
|
/** Remove a file. Idempotent: nothing at `path` is success. */
|
|
75
84
|
removeFile(path: string): Promise<void>;
|
|
76
85
|
/** A user by name or numeric id, or `undefined` when the host has none. */
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ACL half of sudo-guard.ts: mode bits say a directory is root's alone, and an ACL can say
|
|
3
|
+
* otherwise. On macOS an `allow add_file,add_subdirectory,delete_child` entry lets its holder swap
|
|
4
|
+
* what lies under a root-owned `0755` directory — a symlink where the file was — between the
|
|
5
|
+
* guard's lstat checks and the privileged call, exactly as group write would. An inheritable
|
|
6
|
+
* `allow write` hands every file root installs there to its holder.
|
|
7
|
+
*
|
|
8
|
+
* ★ READ WITH `ls -lden`, AS THE OPERATOR — no sudo, and the same read at plan time and before the
|
|
9
|
+
* write. MEASURED 2026-09-21 on macOS 27.2: each entry prints on its own line after the path's,
|
|
10
|
+
* as ` 0: <qualifier> [inherited ]allow|deny <perms>`; `-n` makes the qualifier a UUID, never a
|
|
11
|
+
* name with a space in it. The `+` flag after the mode is NOT reliable — `@` (extended
|
|
12
|
+
* attributes) replaces it in the listing — so the entries are what is read.
|
|
13
|
+
* ⚠️ A DENY ENTRY GRANTS NOTHING (macOS puts `everyone deny delete` on home folders), and an allow
|
|
14
|
+
* of read-only rights (`list`, `search`, `readattr` …) swaps nothing, so neither refuses.
|
|
15
|
+
*/
|
|
16
|
+
import type { HostRunner } from './runner.ts';
|
|
17
|
+
import { SudoRefusedError } from './sudo-allowlist.ts';
|
|
18
|
+
|
|
19
|
+
export const LS = '/bin/ls';
|
|
20
|
+
|
|
21
|
+
/** Rights that let the holder add, remove, rename or re-permission what a directory holds. */
|
|
22
|
+
const WRITE_RIGHTS: ReadonlySet<string> = new Set([
|
|
23
|
+
'add_file',
|
|
24
|
+
'add_subdirectory',
|
|
25
|
+
'append',
|
|
26
|
+
'chown',
|
|
27
|
+
'delete',
|
|
28
|
+
'delete_child',
|
|
29
|
+
'write',
|
|
30
|
+
'writesecurity',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const ENTRY = /^\s*\d+:\s+\S+\s+(?:inherited\s+)?allow\s+(\S+)/;
|
|
34
|
+
|
|
35
|
+
/** The allow entries in `ls -lden` output that grant a write right, as printed. */
|
|
36
|
+
export const aclGrants = (listing: string): string[] =>
|
|
37
|
+
listing
|
|
38
|
+
.split('\n')
|
|
39
|
+
.filter((line) => {
|
|
40
|
+
const rights = ENTRY.exec(line)?.[1];
|
|
41
|
+
return rights !== undefined && rights.split(',').some((right) => WRITE_RIGHTS.has(right));
|
|
42
|
+
})
|
|
43
|
+
.map((line) => line.trim());
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* ⛔ Refuse when any directory in `chain` carries an ACL entry that lets someone write it, or when
|
|
47
|
+
* the ACLs cannot be read at all (fail closed). `path` names the file in the refusal.
|
|
48
|
+
*/
|
|
49
|
+
export const assertNoAclGrants = async (
|
|
50
|
+
base: HostRunner,
|
|
51
|
+
path: string,
|
|
52
|
+
chain: readonly string[],
|
|
53
|
+
): Promise<void> => {
|
|
54
|
+
const listed = await base.exec([LS, '-lden', '--', ...chain]);
|
|
55
|
+
if (listed.exitCode !== 0) {
|
|
56
|
+
throw new SudoRefusedError(
|
|
57
|
+
`sudoRunner ${path}: could not read the ACLs of the directories from / down to it ` +
|
|
58
|
+
`(${LS} -lden exit ${String(listed.exitCode)}). Nothing ran as root.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const grants = aclGrants(listed.stdout);
|
|
62
|
+
if (grants.length === 0) return;
|
|
63
|
+
throw new SudoRefusedError(
|
|
64
|
+
`sudoRunner ${path}: a directory from / down to it carries an ACL that lets someone other ` +
|
|
65
|
+
`than root change what it holds (${grants.join('; ')}). Remove the entry (chmod -a), or ` +
|
|
66
|
+
'declare a prefix without one. Nothing ran as root.',
|
|
67
|
+
);
|
|
68
|
+
};
|