@homeflare/alchemy 0.8.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 +22 -11
- package/dist/caddy/config-lifecycle.d.ts +1 -1
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/index.js +221 -53
- package/dist/caddy/index.js.map +13 -9
- 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 +1 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -1
- package/dist/launchd/host-effect.d.ts +9 -18
- package/dist/launchd/host-effect.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.js +345 -86
- package/dist/launchd/index.js.map +18 -12
- 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.d.ts +2 -1
- package/dist/launchd/job.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +7 -0
- 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-guard.d.ts +31 -4
- package/dist/launchd/sudo-guard.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts +3 -3
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/launchd/sudo-said.d.ts +10 -0
- package/dist/launchd/sudo-said.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.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-families.d.ts +6 -3
- package/dist/openbao/fake-families.d.ts.map +1 -1
- 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/index.js +541 -270
- package/dist/openbao/index.js.map +26 -21
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +6 -2
- 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/role-reconcile.d.ts +11 -1
- package/dist/openbao/role-reconcile.d.ts.map +1 -1
- 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/credentials.md +15 -0
- package/docs/launchd-sudo.md +31 -6
- package/docs/launchd.md +7 -3
- package/docs/ownership.md +113 -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 +1 -1
- package/src/caddy/config-lifecycle.ts +1 -1
- package/src/caddy/config.ts +2 -1
- package/src/launchd/adopt-apply.test.ts +166 -0
- package/src/launchd/fake-runner.ts +26 -4
- package/src/launchd/host-effect.ts +1 -33
- package/src/launchd/host-file-lifecycle.ts +39 -14
- package/src/launchd/host-file.ts +13 -4
- package/src/launchd/job-lifecycle.ts +22 -14
- package/src/launchd/job-preflight.ts +18 -1
- package/src/launchd/job.ts +13 -4
- package/src/launchd/runner.ts +7 -0
- package/src/launchd/sudo-acl.ts +68 -0
- package/src/launchd/sudo-chain.test.ts +121 -0
- package/src/launchd/sudo-guard.ts +89 -10
- package/src/launchd/sudo-modes.test.ts +135 -0
- package/src/launchd/sudo-runner.ts +40 -59
- package/src/launchd/sudo-said.ts +58 -0
- package/src/openbao/README.md +5 -2
- package/src/openbao/REPLACE.md +13 -11
- 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.ts +14 -4
- package/src/openbao/cloudflare-role.ts +17 -4
- package/src/openbao/fake-bao.ts +52 -0
- package/src/openbao/fake-core.ts +141 -0
- package/src/openbao/fake-families.ts +11 -5
- package/src/openbao/fake-mounts.ts +74 -0
- package/src/openbao/fake-stack.ts +17 -4
- package/src/openbao/jwt-config.ts +10 -7
- package/src/openbao/jwt-role.ts +10 -7
- package/src/openbao/kubernetes-role.ts +13 -7
- package/src/openbao/mfa-enforcement.ts +20 -6
- package/src/openbao/mfa-totp.ts +22 -7
- 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 +15 -4
- package/src/openbao/plugin-reconcile.ts +16 -5
- package/src/openbao/plugin.ts +17 -6
- package/src/openbao/policy.ts +17 -4
- package/src/openbao/proxmox-role.ts +13 -4
- package/src/openbao/rename-adoption.test.ts +32 -19
- package/src/openbao/role-reconcile.ts +23 -0
- package/src/openbao/ssh-role.ts +14 -4
- 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
package/src/launchd/runner.ts
CHANGED
|
@@ -73,6 +73,13 @@ export interface HostRunner {
|
|
|
73
73
|
* ⚠️ The parent directory must exist; creating it would invent an owner and mode nobody declared.
|
|
74
74
|
*/
|
|
75
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>;
|
|
76
83
|
/** Remove a file. Idempotent: nothing at `path` is success. */
|
|
77
84
|
removeFile(path: string): Promise<void>;
|
|
78
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
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sudoRunner's red-team refusals (2026-09-21), over the fake host (fake-sudo.ts): a directory ABOVE
|
|
3
|
+
* the prefix that someone other than root may change, and an ACL anywhere from / down that grants a
|
|
4
|
+
* write right. Either lets that someone swap the path between the guard and `sudo -n`. Each is
|
|
5
|
+
* refused before anything is staged or run as root, and at plan time through `checkWrite`.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { OPERATOR, fakeSudoHost } from './fake-sudo.ts';
|
|
9
|
+
import { diffFile, reconcileFile } from './host-file-lifecycle.ts';
|
|
10
|
+
import type { HostRunner } from './runner.ts';
|
|
11
|
+
import { aclGrants, assertNoAclGrants } from './sudo-acl.ts';
|
|
12
|
+
import { SudoRefusedError } from './sudo-allowlist.ts';
|
|
13
|
+
import { aboveDirs } from './sudo-guard.ts';
|
|
14
|
+
|
|
15
|
+
const CONF = '/opt/example/app/a.conf';
|
|
16
|
+
const bytes = (text: string) => new TextEncoder().encode(text);
|
|
17
|
+
type Host = ReturnType<typeof fakeSudoHost>;
|
|
18
|
+
const entry = (kind: 'directory' | 'symlink', mode: number, uid = 0) => ({
|
|
19
|
+
bytes: bytes(''),
|
|
20
|
+
gid: 0,
|
|
21
|
+
kind,
|
|
22
|
+
mode,
|
|
23
|
+
uid,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** As `ls -lden` printed them on macOS 27.2 (sudo-acl.ts): a UUID qualifier, one entry a line. */
|
|
27
|
+
const UUID = 'ABCDEFAB-CDEF-ABCD-EFAB-CDEF00000014';
|
|
28
|
+
|
|
29
|
+
test('aboveDirs is / down to the parent of the prefix', () => {
|
|
30
|
+
expect(aboveDirs('/Library/LaunchDaemons')).toEqual(['/', '/Library']);
|
|
31
|
+
expect(aboveDirs('/opt/example')).toEqual(['/', '/opt']);
|
|
32
|
+
expect(aboveDirs('/opt')).toEqual(['/']);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('aclGrants', () => {
|
|
36
|
+
test('an allow of a write right counts; deny, and read-only rights, do not', () => {
|
|
37
|
+
const listing = [
|
|
38
|
+
`drwxr-xr-x@ 3 0 0 96 Sep 21 19:36 /opt/example`,
|
|
39
|
+
` 0: ${UUID} allow add_file,add_subdirectory,delete_child`,
|
|
40
|
+
` 1: ${UUID} inherited allow write,file_inherit`,
|
|
41
|
+
` 2: ${UUID} deny delete`,
|
|
42
|
+
` 3: ${UUID} allow list,search,readattr`,
|
|
43
|
+
].join('\n');
|
|
44
|
+
expect(aclGrants(listing)).toEqual([
|
|
45
|
+
`0: ${UUID} allow add_file,add_subdirectory,delete_child`,
|
|
46
|
+
`1: ${UUID} inherited allow write,file_inherit`,
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('fails closed when the ACLs cannot be read', async () => {
|
|
51
|
+
const base = { exec: async () => ({ exitCode: 1, stderr: 'ls: x', stdout: '' }) };
|
|
52
|
+
const read = assertNoAclGrants(base as unknown as HostRunner, CONF, ['/']);
|
|
53
|
+
await expect(read).rejects.toThrow('could not read the ACLs');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const refusedBeforeSudo = async (host: Host, says: string | RegExp) => {
|
|
58
|
+
const write = host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o644 });
|
|
59
|
+
await expect(write).rejects.toBeInstanceOf(SudoRefusedError);
|
|
60
|
+
await expect(write).rejects.toThrow(says);
|
|
61
|
+
expect(host.state.stagedCount).toBe(0);
|
|
62
|
+
expect(host.sudoCalls).toEqual([]);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
describe('every directory above the prefix', () => {
|
|
66
|
+
test.each([
|
|
67
|
+
['owned by the operator', (h: Host) => h.fake.dirs.set('/opt', OPERATOR), 'uid 501'],
|
|
68
|
+
['group-writable', (h: Host) => h.fake.files.set('/opt', entry('directory', 0o775)), '0775'],
|
|
69
|
+
[
|
|
70
|
+
'a symlink the operator owns',
|
|
71
|
+
(h: Host) => h.fake.files.set('/opt', entry('symlink', 0o755, OPERATOR)),
|
|
72
|
+
'symlink',
|
|
73
|
+
],
|
|
74
|
+
] as const)('%s is refused before staging or sudo', async (_n, setup, says) => {
|
|
75
|
+
const host = fakeSudoHost();
|
|
76
|
+
setup(host);
|
|
77
|
+
await refusedBeforeSudo(host, says);
|
|
78
|
+
await expect(host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o644 })).rejects.toThrow(
|
|
79
|
+
/\/opt, above the declared prefix \/opt\/example/,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a root-owned symlink (/etc -> private/etc) is root's own, and followed", async () => {
|
|
84
|
+
const host = fakeSudoHost();
|
|
85
|
+
host.fake.files.set('/opt', entry('symlink', 0o755));
|
|
86
|
+
await host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o644 });
|
|
87
|
+
expect(host.fake.files.get(CONF)).toMatchObject({ mode: 0o644, uid: 0 });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('a HostFile plan that would write under it fails, still without sudo', async () => {
|
|
91
|
+
const host = fakeSudoHost();
|
|
92
|
+
const output = await reconcileFile(host.runner, { content: 'x', path: CONF });
|
|
93
|
+
host.fake.dirs.set('/opt', OPERATOR);
|
|
94
|
+
const before = host.sudoCalls.length;
|
|
95
|
+
await expect(diffFile(host.runner, { content: 'y', path: CONF }, output)).rejects.toThrow(
|
|
96
|
+
'uid 501',
|
|
97
|
+
);
|
|
98
|
+
expect(host.sudoCalls).toHaveLength(before);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('an ACL from / down to the file', () => {
|
|
103
|
+
test.each(['/', '/opt', '/opt/example', '/opt/example/app'])(
|
|
104
|
+
'that grants a write right on %s is refused before staging or sudo',
|
|
105
|
+
async (dir) => {
|
|
106
|
+
const host = fakeSudoHost();
|
|
107
|
+
host.fake.acls.set(dir, [` 0: ${UUID} allow add_file,delete_child`]);
|
|
108
|
+
await refusedBeforeSudo(host, 'carries an ACL');
|
|
109
|
+
host.fake.files.set(CONF, { bytes: bytes('x'), gid: 0, kind: 'file', mode: 0o644, uid: 0 });
|
|
110
|
+
await expect(host.runner.removeFile(CONF)).rejects.toThrow('carries an ACL');
|
|
111
|
+
expect(host.sudoCalls).toEqual([]);
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
test('a deny-only ACL (as on a home folder) is no refusal', async () => {
|
|
116
|
+
const host = fakeSudoHost();
|
|
117
|
+
host.fake.acls.set('/opt/example/app', [` 0: ${UUID} deny delete`]);
|
|
118
|
+
await host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o644 });
|
|
119
|
+
expect(host.fake.files.get(CONF)).toMatchObject({ uid: 0 });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -5,12 +5,23 @@
|
|
|
5
5
|
* ⛔ WHAT IS LOGGED IS WHAT IS WRITTEN. A symlinked directory under a prefix would make
|
|
6
6
|
* `install` or `rm`, running as root, act on a path the log never named. So every directory
|
|
7
7
|
* between the prefix and the file must be a real directory.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* ⛔ EVERY DIRECTORY FROM THE PREFIX DOWN IS ROOT'S ALONE (decided 2026-09-21). A directory below
|
|
9
|
+
* the prefix that another user owns, or that group or other may write, lets that user swap what
|
|
10
|
+
* lies under it — a symlink where the file was — between these checks and the privileged call.
|
|
11
|
+
* So each one must be owned by root and not group/other-writable (rootOnlyProblem), exactly as
|
|
12
|
+
* the prefix itself must.
|
|
13
|
+
* ⛔ AND EVERY DIRECTORY ABOVE THE PREFIX (red team, 2026-09-21). Whoever may write the prefix's
|
|
14
|
+
* parent may rename the prefix away and put a symlink in its place between these checks and the
|
|
15
|
+
* privileged call — the same swap one level up. So `/` down to the prefix's parent must be
|
|
16
|
+
* root-only too (a root-owned symlink among them, `/etc` -> `private/etc`, is root's own and is
|
|
17
|
+
* followed; its target is root's choice, and not walked).
|
|
18
|
+
* ⛔ AND NO ACL THAT SAYS OTHERWISE (sudo-acl.ts): mode bits are not the whole answer on macOS.
|
|
19
|
+
* ⚠️ These checks run before the privileged call, as the deploying user. They guard against a
|
|
20
|
+
* mistaken declaration and against every user but root; root itself could still change a path
|
|
21
|
+
* in that window.
|
|
12
22
|
*/
|
|
13
23
|
import type { FileStat, HostRunner, WriteOptions } from './runner.ts';
|
|
24
|
+
import { assertNoAclGrants } from './sudo-acl.ts';
|
|
14
25
|
import { SudoRefusedError } from './sudo-allowlist.ts';
|
|
15
26
|
|
|
16
27
|
const refuse = (path: string, message: string): Error =>
|
|
@@ -25,6 +36,12 @@ export const betweenDirs = (prefix: string, path: string): string[] => {
|
|
|
25
36
|
return parts.map((_, index) => `${prefix}/${parts.slice(0, index + 1).join('/')}`);
|
|
26
37
|
};
|
|
27
38
|
|
|
39
|
+
/** The directories strictly above `prefix`: `/`, then each one down to the prefix's parent. */
|
|
40
|
+
export const aboveDirs = (prefix: string): string[] => {
|
|
41
|
+
const parts = prefix.split('/').slice(1, -1);
|
|
42
|
+
return ['/', ...parts.map((_, index) => `/${parts.slice(0, index + 1).join('/')}`)];
|
|
43
|
+
};
|
|
44
|
+
|
|
28
45
|
/**
|
|
29
46
|
* What must be at `path` itself: a regular file, or (for a write or a remove) nothing yet.
|
|
30
47
|
* ⚠️ A DIRECTORY AT THE DESTINATION IS THE TRAP: `install src dir` copies INTO the directory
|
|
@@ -33,17 +50,17 @@ export const betweenDirs = (prefix: string, path: string): string[] => {
|
|
|
33
50
|
export type Expect = 'file' | 'file-or-absent';
|
|
34
51
|
|
|
35
52
|
/**
|
|
36
|
-
* ⛔ THE PREFIX
|
|
37
|
-
* trusted from the declaration. A symlinked prefix makes root write wherever it points, which the
|
|
53
|
+
* ⛔ THE PREFIX, AND EVERY DIRECTORY BELOW IT ON THE WAY TO THE FILE, MUST BE A DIRECTORY ONLY ROOT
|
|
54
|
+
* CAN CHANGE, checked at every call rather than trusted from the declaration. A symlinked prefix makes root write wherever it points, which the
|
|
38
55
|
* log never names (`/opt/example` -> `/etc` turns a HostFile at `/opt/example/sudoers` into
|
|
39
56
|
* `/etc/sudoers`). A prefix another user owns or may write lets that user swap a directory below
|
|
40
57
|
* it for a symlink between this check and the privileged call.
|
|
41
58
|
* ⚠️ lstat of the prefix itself: a symlink ABOVE it (`/etc` -> `/private/etc` on macOS) is the
|
|
42
|
-
* system's and is followed, as every path lookup does
|
|
59
|
+
* system's and is followed, as every path lookup does — when root owns it (aboveDirs).
|
|
43
60
|
* ★ MEASURED 2026-09-21 on macOS 27.2 (`ls -ldn`): /Library/LaunchDaemons, /private/etc, /opt and
|
|
44
61
|
* /usr/local are uid 0, mode 0755, so the prefixes a host stack declares pass as they are.
|
|
45
62
|
*/
|
|
46
|
-
const
|
|
63
|
+
const rootOnlyProblem = (stat: FileStat | undefined): string | undefined => {
|
|
47
64
|
if (stat === undefined) return 'is missing';
|
|
48
65
|
if (stat.kind !== 'directory') return `is a ${stat.kind}, not a directory`;
|
|
49
66
|
if (stat.uid !== 0) return `is owned by uid ${String(stat.uid)}, not root`;
|
|
@@ -63,9 +80,20 @@ export const assertPlainPath = async (
|
|
|
63
80
|
path: string,
|
|
64
81
|
expect: Expect,
|
|
65
82
|
): Promise<'file' | 'absent'> => {
|
|
83
|
+
const above = aboveDirs(prefix);
|
|
84
|
+
for (const [index, stat] of (await Promise.all(above.map((dir) => base.stat(dir)))).entries()) {
|
|
85
|
+
const up = stat?.kind === 'symlink' && stat.uid === 0 ? undefined : rootOnlyProblem(stat);
|
|
86
|
+
if (up !== undefined) {
|
|
87
|
+
throw refuse(
|
|
88
|
+
path,
|
|
89
|
+
`${String(above[index])}, above the declared prefix ${prefix}, ${up}. Whoever may change it ` +
|
|
90
|
+
'may swap the prefix itself; every directory from / down must be root-only. Nothing ran as root.',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
66
94
|
const dirs = betweenDirs(prefix, path);
|
|
67
95
|
const [prefixStat, ...stats] = await Promise.all([prefix, ...dirs].map((dir) => base.stat(dir)));
|
|
68
|
-
const bad =
|
|
96
|
+
const bad = rootOnlyProblem(prefixStat);
|
|
69
97
|
if (bad !== undefined) {
|
|
70
98
|
throw refuse(
|
|
71
99
|
path,
|
|
@@ -74,20 +102,71 @@ export const assertPlainPath = async (
|
|
|
74
102
|
);
|
|
75
103
|
}
|
|
76
104
|
for (const [index, stat] of stats.entries()) {
|
|
105
|
+
const dir = String(dirs[index]);
|
|
77
106
|
if (stat?.kind !== 'directory') {
|
|
78
107
|
throw refuse(
|
|
79
108
|
path,
|
|
80
|
-
`${
|
|
109
|
+
`${dir} is ${stat === undefined ? 'missing' : `a ${stat.kind}`}; root ` +
|
|
81
110
|
'writes only through real directories under a declared prefix.',
|
|
82
111
|
);
|
|
83
112
|
}
|
|
113
|
+
const below = rootOnlyProblem(stat);
|
|
114
|
+
if (below !== undefined) {
|
|
115
|
+
throw refuse(
|
|
116
|
+
path,
|
|
117
|
+
`${dir} ${below}. Every directory from the prefix ${prefix} down to the file must be one ` +
|
|
118
|
+
'that root owns and only root may write. Nothing ran as root.',
|
|
119
|
+
);
|
|
120
|
+
}
|
|
84
121
|
}
|
|
122
|
+
await assertNoAclGrants(base, path, [...above, prefix, ...dirs]);
|
|
85
123
|
const target = await base.stat(path);
|
|
86
124
|
if (target === undefined && expect === 'file-or-absent') return 'absent';
|
|
87
125
|
if (target?.kind === 'file') return 'file';
|
|
88
126
|
throw refuse(path, `is ${target === undefined ? 'missing' : `a ${target.kind}`}, not a file`);
|
|
89
127
|
};
|
|
90
128
|
|
|
129
|
+
/**
|
|
130
|
+
* ⛔ A ROOT-OWNED FILE UNDER A PREFIX IS ROOT'S TO CHANGE, AND ONLY ROOT'S (decided 2026-09-21).
|
|
131
|
+
* Group- or world-writable, anyone in that class rewrites a file root installed — a daemon's
|
|
132
|
+
* config, a script a LaunchDaemon runs as root — which is root by another name. Setuid or setgid,
|
|
133
|
+
* it runs as root (or its group) for whoever executes it. install(1) would apply either, as root.
|
|
134
|
+
* ★ Root-owned is an omitted owner (the runner installs as root under a prefix) or uid 0. A file
|
|
135
|
+
* handed to another user is that user's to change; the sticky bit changes nothing on a file.
|
|
136
|
+
*/
|
|
137
|
+
export const modeProblem = (options: WriteOptions): string | undefined => {
|
|
138
|
+
if (options.uid !== undefined && options.uid !== 0) return undefined;
|
|
139
|
+
const found: string[] = [];
|
|
140
|
+
if ((options.mode & 0o6000) !== 0) found.push('setuid/setgid');
|
|
141
|
+
if ((options.mode & 0o022) !== 0) found.push('writable by group or other');
|
|
142
|
+
if (found.length === 0) return undefined;
|
|
143
|
+
return `mode ${options.mode.toString(8).padStart(4, '0')} is ${found.join(' and ')} on a root-owned file`;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Every check a write under `prefix` meets before anything is staged or run as root: the mode, the
|
|
148
|
+
* path (assertPlainPath) and the read-back (assertReadableBack). ★ Reads only, as the deploying
|
|
149
|
+
* user — so sudoRunner's `checkWrite` runs it at PLAN time too, and a plan still never calls sudo.
|
|
150
|
+
*/
|
|
151
|
+
export const assertInstallable = async (
|
|
152
|
+
base: HostRunner,
|
|
153
|
+
prefix: string,
|
|
154
|
+
path: string,
|
|
155
|
+
options: WriteOptions,
|
|
156
|
+
groups: readonly number[] | undefined,
|
|
157
|
+
): Promise<void> => {
|
|
158
|
+
const mode = modeProblem(options);
|
|
159
|
+
if (mode !== undefined) {
|
|
160
|
+
throw refuse(
|
|
161
|
+
path,
|
|
162
|
+
`${mode}. Declare a mode without those bits (0644, 0640, 0755), or give the file to its ` +
|
|
163
|
+
'real owner. Nothing ran as root.',
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
await assertPlainPath(base, prefix, path, 'file-or-absent');
|
|
167
|
+
await assertReadableBack(base, path, options, groups);
|
|
168
|
+
};
|
|
169
|
+
|
|
91
170
|
/**
|
|
92
171
|
* POSIX read permission for one uid: owner class, else group class, else other. `undefined` when
|
|
93
172
|
* only group membership could decide and the groups are unknown.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sudoRunner's 2026-09-21 refusals, over the fake host (fake-sudo.ts): no group/world-writable,
|
|
3
|
+
* setuid or setgid root-owned file under a prefix, and no directory from the prefix down that root
|
|
4
|
+
* does not own alone. Each is refused before anything is staged or run as root — and, through
|
|
5
|
+
* `checkWrite`, at PLAN time, still without sudo.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { OPERATOR, fakeSudoHost } from './fake-sudo.ts';
|
|
9
|
+
import { diffFile, reconcileFile } from './host-file-lifecycle.ts';
|
|
10
|
+
import type { LaunchdJobProps } from './job-form.ts';
|
|
11
|
+
import { diffJob, reconcileJob } from './job-lifecycle.ts';
|
|
12
|
+
import { SudoRefusedError } from './sudo-allowlist.ts';
|
|
13
|
+
import { modeProblem } from './sudo-guard.ts';
|
|
14
|
+
|
|
15
|
+
const CONF = '/opt/example/app/a.conf';
|
|
16
|
+
const bytes = (text: string) => new TextEncoder().encode(text);
|
|
17
|
+
type Host = ReturnType<typeof fakeSudoHost>;
|
|
18
|
+
const dir = (mode: number, uid = 0) => ({
|
|
19
|
+
bytes: bytes(''),
|
|
20
|
+
gid: 0,
|
|
21
|
+
kind: 'directory' as const,
|
|
22
|
+
mode,
|
|
23
|
+
uid,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('modeProblem', () => {
|
|
27
|
+
test.each([
|
|
28
|
+
['0644 root', { mode: 0o644, uid: 0 }, undefined],
|
|
29
|
+
['0755 with the owner omitted (root under a prefix)', { mode: 0o755 }, undefined],
|
|
30
|
+
['0640 root', { mode: 0o640, uid: 0 }, undefined],
|
|
31
|
+
['sticky 1755 changes nothing on a file', { mode: 0o1755 }, undefined],
|
|
32
|
+
['0664 root is group-writable', { mode: 0o664 }, 'writable by group or other'],
|
|
33
|
+
['0646 root is world-writable', { mode: 0o646, uid: 0 }, 'writable by group or other'],
|
|
34
|
+
['4755 root is setuid', { mode: 0o4755 }, 'setuid/setgid'],
|
|
35
|
+
['2755 root is setgid', { mode: 0o2755, uid: 0 }, 'setuid/setgid'],
|
|
36
|
+
['6777 root is both', { mode: 0o6777 }, 'setuid/setgid and writable by group or other'],
|
|
37
|
+
['0664 handed to another user is theirs to change', { mode: 0o664, uid: OPERATOR }, undefined],
|
|
38
|
+
] as const)('%s', (_name, options, found) => {
|
|
39
|
+
const problem = modeProblem(options);
|
|
40
|
+
if (found === undefined) expect(problem).toBeUndefined();
|
|
41
|
+
else expect(problem).toContain(found);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('a root-owned install under a prefix', () => {
|
|
46
|
+
test.each([0o664, 0o646, 0o4755, 0o2755])(
|
|
47
|
+
'mode %o is refused before staging or sudo',
|
|
48
|
+
async (mode) => {
|
|
49
|
+
const host = fakeSudoHost();
|
|
50
|
+
const write = host.runner.writeFileAtomic(CONF, bytes('x'), { mode });
|
|
51
|
+
await expect(write).rejects.toBeInstanceOf(SudoRefusedError);
|
|
52
|
+
await expect(write).rejects.toThrow('on a root-owned file');
|
|
53
|
+
expect(host.state.stagedCount).toBe(0);
|
|
54
|
+
expect(host.sudoCalls).toEqual([]);
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
test('an omitted-owner 0644 still installs, as root', async () => {
|
|
59
|
+
const host = fakeSudoHost();
|
|
60
|
+
await host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o644 });
|
|
61
|
+
expect(host.fake.files.get(CONF)).toMatchObject({ mode: 0o644, uid: 0 });
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('every directory from the prefix down to the file', () => {
|
|
66
|
+
const below: [string, (h: Host) => void, string][] = [
|
|
67
|
+
['owned by the operator', (h) => h.fake.dirs.set('/opt/example/app', OPERATOR), 'uid 501'],
|
|
68
|
+
['group-writable', (h) => h.fake.files.set('/opt/example/app', dir(0o775)), '0775'],
|
|
69
|
+
['world-writable', (h) => h.fake.files.set('/opt/example/app', dir(0o757)), '0757'],
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
test.each(below)('%s is refused for a write, before staging or sudo', async (_n, setup, says) => {
|
|
73
|
+
const host = fakeSudoHost();
|
|
74
|
+
setup(host);
|
|
75
|
+
const write = host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o644 });
|
|
76
|
+
await expect(write).rejects.toBeInstanceOf(SudoRefusedError);
|
|
77
|
+
await expect(write).rejects.toThrow(
|
|
78
|
+
/\/opt\/example\/app is .*(uid 501|0775|0757).*Every directory from the prefix/s,
|
|
79
|
+
);
|
|
80
|
+
await expect(write).rejects.toThrow(says);
|
|
81
|
+
expect(host.state.stagedCount).toBe(0);
|
|
82
|
+
expect(host.sudoCalls).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test.each(below)('%s is refused for a remove too', async (_n, setup, says) => {
|
|
86
|
+
const host = fakeSudoHost();
|
|
87
|
+
host.fake.files.set(CONF, { ...dir(0o644), kind: 'file' });
|
|
88
|
+
setup(host);
|
|
89
|
+
await expect(host.runner.removeFile(CONF)).rejects.toThrow(says);
|
|
90
|
+
expect(host.sudoCalls).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('at plan time, through checkWrite — reads only, never sudo', () => {
|
|
95
|
+
test('HostFile: a diff that would write a setuid root file fails the plan', async () => {
|
|
96
|
+
const host = fakeSudoHost();
|
|
97
|
+
const output = await reconcileFile(host.runner, { content: 'x', mode: 0o755, path: CONF });
|
|
98
|
+
const before = host.sudoCalls.length;
|
|
99
|
+
const plan = diffFile(host.runner, { content: 'x', mode: 0o4755, path: CONF }, output);
|
|
100
|
+
await expect(plan).rejects.toThrow('setuid/setgid');
|
|
101
|
+
expect(host.sudoCalls).toHaveLength(before);
|
|
102
|
+
// ★ A converged file plans noop and asks nothing, so an unrelated plan is never blocked by it.
|
|
103
|
+
expect(await diffFile(host.runner, { content: 'x', mode: 0o755, path: CONF }, output)).toEqual({
|
|
104
|
+
action: 'noop',
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('HostFile: a new path under a directory another user may write fails the plan', async () => {
|
|
109
|
+
const host = fakeSudoHost();
|
|
110
|
+
const output = await reconcileFile(host.runner, { content: 'x', path: CONF });
|
|
111
|
+
host.fake.dirs.set('/opt/example/other', OPERATOR);
|
|
112
|
+
const moved = { content: 'x', path: '/opt/example/other/a.conf' };
|
|
113
|
+
await expect(diffFile(host.runner, moved, output)).rejects.toThrow('uid 501');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test.each([
|
|
117
|
+
['an update', { runAtLoad: true }],
|
|
118
|
+
['a rename (the delete-first replace)', { label: 'com.example.renamed' }],
|
|
119
|
+
] as const)(
|
|
120
|
+
'LaunchdJob: %s into a group-writable LaunchDaemons fails the plan',
|
|
121
|
+
async (_n, change) => {
|
|
122
|
+
const job: LaunchdJobProps = {
|
|
123
|
+
domain: 'system',
|
|
124
|
+
label: 'com.example.job',
|
|
125
|
+
programArguments: ['/usr/local/bin/job'],
|
|
126
|
+
};
|
|
127
|
+
const host = fakeSudoHost();
|
|
128
|
+
const attrs = await reconcileJob(host.runner, job, undefined);
|
|
129
|
+
host.fake.files.set('/Library/LaunchDaemons', dir(0o775));
|
|
130
|
+
const before = host.sudoCalls.length;
|
|
131
|
+
await expect(diffJob(host.runner, { ...job, ...change }, attrs)).rejects.toThrow('0775');
|
|
132
|
+
expect(host.sudoCalls).toHaveLength(before);
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
});
|
|
@@ -21,12 +21,12 @@
|
|
|
21
21
|
* ★ `install -S`: install(1) on macOS 27.2 (read 2026-09-21) always writes a temp file in the
|
|
22
22
|
* target directory and renames it, so the file is never torn, and -S adds the fsync that
|
|
23
23
|
* localRunner's `handle.sync()` does.
|
|
24
|
-
* ⚠️ REASONED, NOT MEASURED: sudo's failure text
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
27
|
*/
|
|
28
|
-
import { type LocalRunnerOptions,
|
|
29
|
-
import type { ExecResult, HostRunner } from './runner.ts';
|
|
28
|
+
import { type LocalRunnerOptions, localRunner } from './local-runner.ts';
|
|
29
|
+
import type { ExecResult, HostRunner, WriteOptions } from './runner.ts';
|
|
30
30
|
import {
|
|
31
31
|
INSTALL,
|
|
32
32
|
RM,
|
|
@@ -38,7 +38,8 @@ import {
|
|
|
38
38
|
privilegedProblem,
|
|
39
39
|
routeExec,
|
|
40
40
|
} from './sudo-allowlist.ts';
|
|
41
|
-
import {
|
|
41
|
+
import { assertInstallable, assertPlainPath, operatorGroups } from './sudo-guard.ts';
|
|
42
|
+
import { sudoRefusal, undeclared } from './sudo-said.ts';
|
|
42
43
|
import { type Staged, stageFile } from './sudo-stage.ts';
|
|
43
44
|
|
|
44
45
|
export { SudoRefusedError };
|
|
@@ -66,31 +67,6 @@ export type SudoDeps = {
|
|
|
66
67
|
readonly log: (line: string) => void;
|
|
67
68
|
};
|
|
68
69
|
|
|
69
|
-
/**
|
|
70
|
-
* ⚠️ ANCHORED TO sudo'S OWN MESSAGE FORMS (`sudo: …` lines, `Sorry, user …`), so a launchctl or
|
|
71
|
-
* install error that happens to say "not allowed" is never reported as "the command did not run".
|
|
72
|
-
* The refusals are the three a sudoers denial prints: the command not allowed, the user not in
|
|
73
|
-
* sudoers, and the user not allowed on this host (`<user> is not allowed to run sudo on <host>.`).
|
|
74
|
-
* REASONED from sudo 1.9's sudoers plugin, like the rest (header).
|
|
75
|
-
*/
|
|
76
|
-
const PASSWORD =
|
|
77
|
-
/^sudo: (?:a password is required|a terminal is required|sorry, you must have a tty)/m;
|
|
78
|
-
const NOT_ALLOWED =
|
|
79
|
-
/^(?: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;
|
|
80
|
-
|
|
81
|
-
/** ★ An EACCES outside every prefix is almost always a directory the stack forgot to declare. */
|
|
82
|
-
const undeclared =
|
|
83
|
-
(path: string, prefixes: readonly string[]) =>
|
|
84
|
-
(cause: unknown): never => {
|
|
85
|
-
const code = errnoCode(cause);
|
|
86
|
-
if (code !== 'EACCES' && code !== 'EPERM') throw cause;
|
|
87
|
-
throw new Error(
|
|
88
|
-
`sudoRunner ${path}: ${code} as the deploying user, and the path is under no declared prefix ` +
|
|
89
|
-
`(${prefixes.join(', ')}). If root owns its directory, declare that directory as a prefix.`,
|
|
90
|
-
{ cause },
|
|
91
|
-
);
|
|
92
|
-
};
|
|
93
|
-
|
|
94
70
|
export const makeSudoRunner = (prefixList: readonly string[], deps: SudoDeps): HostRunner => {
|
|
95
71
|
const prefixes = checkPrefixes(prefixList);
|
|
96
72
|
const { base } = deps;
|
|
@@ -113,22 +89,35 @@ export const makeSudoRunner = (prefixList: readonly string[], deps: SudoDeps): H
|
|
|
113
89
|
await before?.();
|
|
114
90
|
deps.log(`homeflare/launchd sudo -n ${shown}`);
|
|
115
91
|
const result = await base.exec([SUDO, '-n', '--', ...argv]);
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
throw new SudoRefusedError(
|
|
119
|
-
`sudo -n ${shown}: a password is required, and this runner never prompts. Run \`sudo -v\` ` +
|
|
120
|
-
'in the deploying terminal just before the deploy, or grant exactly these commands ' +
|
|
121
|
-
'NOPASSWD (docs/launchd-sudo.md). The command did not run.',
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
if (NOT_ALLOWED.test(result.stderr)) {
|
|
125
|
-
throw new SudoRefusedError(
|
|
126
|
-
`sudo -n ${shown}: sudoers does not let the deploying user run this. The command did not run.`,
|
|
127
|
-
);
|
|
128
|
-
}
|
|
92
|
+
const refused = sudoRefusal(shown, result);
|
|
93
|
+
if (refused !== undefined) throw refused;
|
|
129
94
|
return result;
|
|
130
95
|
};
|
|
131
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
|
+
|
|
132
121
|
/** A privileged file operation: anything but exit 0 is an error. ⚠️ stderr names paths only. */
|
|
133
122
|
const mustSucceed = async (argv: readonly string[], staged?: string): Promise<void> => {
|
|
134
123
|
const result = await elevate(argv, staged === undefined ? {} : { staged });
|
|
@@ -139,6 +128,10 @@ export const makeSudoRunner = (prefixList: readonly string[], deps: SudoDeps): H
|
|
|
139
128
|
};
|
|
140
129
|
|
|
141
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
|
+
},
|
|
142
135
|
effectiveUid: () => base.effectiveUid(),
|
|
143
136
|
exec: async (argv) => {
|
|
144
137
|
const route = routeExec(argv, base.effectiveUid());
|
|
@@ -177,23 +170,11 @@ export const makeSudoRunner = (prefixList: readonly string[], deps: SudoDeps): H
|
|
|
177
170
|
sleep: (ms) => base.sleep(ms),
|
|
178
171
|
stat: (path) => base.stat(path),
|
|
179
172
|
writeFileAtomic: async (path, bytes, options) => {
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
if (prefix === undefined) {
|
|
183
|
-
if (options.uid !== undefined && options.uid !== operator && operator !== 0) {
|
|
184
|
-
throw new SudoRefusedError(
|
|
185
|
-
`sudoRunner ${path}: owner ${String(options.uid)} needs root, and the path is under no ` +
|
|
186
|
-
`declared prefix (${prefixes.join(', ')}). Declare its directory, or deploy as root. ` +
|
|
187
|
-
'Nothing was written.',
|
|
188
|
-
);
|
|
189
|
-
}
|
|
173
|
+
const vetted = await vetWrite(path, options);
|
|
174
|
+
if (vetted === undefined) {
|
|
190
175
|
return base.writeFileAtomic(path, bytes, options).catch(undeclared(path, prefixes));
|
|
191
176
|
}
|
|
192
|
-
const mode =
|
|
193
|
-
if (mode === undefined)
|
|
194
|
-
throw new SudoRefusedError(`sudoRunner ${path}: mode must be 0–0o7777`);
|
|
195
|
-
await assertPlainPath(base, prefix, path, 'file-or-absent');
|
|
196
|
-
await assertReadableBack(base, path, options, await deps.groups());
|
|
177
|
+
const { mode } = vetted;
|
|
197
178
|
const staged = await deps.stage(bytes);
|
|
198
179
|
try {
|
|
199
180
|
await mustSucceed(
|