@homeflare/alchemy 0.25.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/launchd/index.js +51 -47
  2. package/dist/launchd/index.js.map +5 -4
  3. package/dist/launchd/sudo-guard-mode.d.ts +62 -0
  4. package/dist/launchd/sudo-guard-mode.d.ts.map +1 -0
  5. package/dist/launchd/sudo-guard.d.ts +1 -38
  6. package/dist/launchd/sudo-guard.d.ts.map +1 -1
  7. package/dist/linux/fake-sudo-root.d.ts +26 -0
  8. package/dist/linux/fake-sudo-root.d.ts.map +1 -0
  9. package/dist/linux/fake-sudo.d.ts +56 -0
  10. package/dist/linux/fake-sudo.d.ts.map +1 -0
  11. package/dist/linux/index.d.ts +2 -0
  12. package/dist/linux/index.d.ts.map +1 -1
  13. package/dist/linux/index.js +1134 -2
  14. package/dist/linux/index.js.map +22 -5
  15. package/dist/linux/sudo-allowlist-chown.d.ts +12 -0
  16. package/dist/linux/sudo-allowlist-chown.d.ts.map +1 -0
  17. package/dist/linux/sudo-allowlist-dir.d.ts +6 -0
  18. package/dist/linux/sudo-allowlist-dir.d.ts.map +1 -0
  19. package/dist/linux/sudo-allowlist.d.ts +40 -0
  20. package/dist/linux/sudo-allowlist.d.ts.map +1 -0
  21. package/dist/linux/sudo-exec.d.ts +31 -0
  22. package/dist/linux/sudo-exec.d.ts.map +1 -0
  23. package/dist/linux/sudo-guard.d.ts +36 -0
  24. package/dist/linux/sudo-guard.d.ts.map +1 -0
  25. package/dist/linux/sudo-runner.d.ts +37 -0
  26. package/dist/linux/sudo-runner.d.ts.map +1 -0
  27. package/dist/linux/sudo-stage.d.ts +22 -0
  28. package/dist/linux/sudo-stage.d.ts.map +1 -0
  29. package/dist/linux/sudo-write.d.ts +25 -0
  30. package/dist/linux/sudo-write.d.ts.map +1 -0
  31. package/dist/linux/unit-form.d.ts +2 -0
  32. package/dist/linux/unit-form.d.ts.map +1 -1
  33. package/dist/litellm/fake-litellm.d.ts.map +1 -1
  34. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -1
  35. package/docs/launchd-sudo.md +12 -3
  36. package/docs/linux-host.md +3 -3
  37. package/docs/linux-sudo.md +187 -0
  38. package/package.json +1 -1
  39. package/src/launchd/sudo-guard-mode.ts +111 -0
  40. package/src/launchd/sudo-guard.ts +3 -79
  41. package/src/launchd/sudo-modes.test.ts +38 -1
  42. package/src/linux/fake-sudo-root.ts +157 -0
  43. package/src/linux/fake-sudo.ts +142 -0
  44. package/src/linux/index.ts +2 -0
  45. package/src/linux/sudo-allowlist-chown.ts +71 -0
  46. package/src/linux/sudo-allowlist-dir.ts +103 -0
  47. package/src/linux/sudo-allowlist.test.ts +252 -0
  48. package/src/linux/sudo-allowlist.ts +223 -0
  49. package/src/linux/sudo-exec.ts +118 -0
  50. package/src/linux/sudo-guard.test.ts +136 -0
  51. package/src/linux/sudo-guard.ts +142 -0
  52. package/src/linux/sudo-lifecycle.test.ts +151 -0
  53. package/src/linux/sudo-runner.test.ts +206 -0
  54. package/src/linux/sudo-runner.ts +164 -0
  55. package/src/linux/sudo-stage.ts +50 -0
  56. package/src/linux/sudo-write.ts +167 -0
  57. package/src/linux/unit-form.ts +2 -1
  58. package/src/litellm/fake-litellm.ts +2 -0
  59. package/src/proxmox/client.test.ts +17 -3
  60. package/src/proxmox/fake-pve-lxc.ts +10 -1
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `sshSudoRunner.exec()`'s root branch: the host reads that decide whether a routed-to-root
3
+ * directory or systemctl call may actually elevate. Split out of sudo-runner.ts, which keeps only
4
+ * the wiring. sudo-allowlist.ts's `routeExec` already decided root vs operator vs refuse from the
5
+ * argv alone; this is the part that needs the host.
6
+ *
7
+ * ⛔ THE FragmentPath CHECK IS WHAT MAKES `pveproxy.service` UNREACHABLE. A vendor unit's file
8
+ * lives under `/usr/lib/systemd/system` or `/lib/systemd/system` — never a prefix this runner
9
+ * would declare — so `enable`/`start`/… on it is refused before sudo runs. A unit with NO file
10
+ * at all (FragmentPath empty) is let through for `stop`/`disable` only, because
11
+ * `unit-lifecycle.ts` `deleteUnit` calls exactly those two on a unit whose file this same
12
+ * delete already removed a moment before — refusing that would make a second, idempotent
13
+ * delete pass fail.
14
+ * 🔴 MEASURED (adversarial review, round 2, 2026-09-23): two gaps in this check, both closed here.
15
+ * (1) A MASKED unit (`systemctl mask`, a symlink to `/dev/null`) also reads `FragmentPath=`
16
+ * empty, so `enable`/`start`/`restart` on a masked `pveproxy.service` would have elevated —
17
+ * `systemctl` itself would then refuse the write, but nothing here should even try; masking is
18
+ * someone's deliberate decision (`unit-preflight.ts`'s `assertUsable` already refuses it at the
19
+ * PROVIDER layer, and this is the same rule enforced again at the lowest level, in case a
20
+ * caller reaches `exec()` directly). (2) The empty-FragmentPath exemption applied to EVERY
21
+ * write verb, not just the two `deleteUnit` actually needs — so `enable`/`start`/`restart` on a
22
+ * kernel-generated pseudo-unit with no file at all (`init.scope`, a `session-N.scope`; `.scope`
23
+ * is a valid type in `unit-form.ts`'s `TYPES` set) would also have elevated. Grepped: no code
24
+ * in this package ever calls `enable`/`start`/`restart` on a unit it has not itself just
25
+ * written a file for, so narrowing the exemption to `stop`/`disable` costs nothing legitimate.
26
+ */
27
+ import type { ExecResult, HostRunner } from '../launchd/runner.ts';
28
+ import { SYSTEMCTL_ABS, SudoRefusedError, canonicalize, prefixOf } from './sudo-allowlist.ts';
29
+ import { type ChainExpect, assertGuardedChain } from './sudo-guard.ts';
30
+ import type { Elevate } from './sudo-write.ts';
31
+ import { parseShow } from './systemctl.ts';
32
+
33
+ const DIR_EXPECT: Readonly<Record<string, ChainExpect>> = {
34
+ chmod: 'directory',
35
+ chown: 'directory',
36
+ mkdir: 'absent',
37
+ rmdir: 'directory',
38
+ };
39
+
40
+ /** The only two verbs `deleteUnit` ever sends against a unit whose file may already be gone. */
41
+ const EMPTY_FRAGMENT_OK: ReadonlySet<string> = new Set(['stop', 'disable']);
42
+
43
+ const unitStatusOf = async (
44
+ base: HostRunner,
45
+ unit: string,
46
+ ): Promise<{ readonly fragmentPath: string; readonly loadState: string }> => {
47
+ const result = await base.exec([
48
+ SYSTEMCTL_ABS,
49
+ 'show',
50
+ '--no-pager',
51
+ '-p',
52
+ 'FragmentPath,LoadState',
53
+ '--',
54
+ unit,
55
+ ]);
56
+ if (result.exitCode !== 0) {
57
+ throw new SudoRefusedError(
58
+ `sshSudoRunner ${unit}: could not read FragmentPath/LoadState (systemctl show exit ` +
59
+ `${String(result.exitCode)}). Nothing ran as root.`,
60
+ );
61
+ }
62
+ const fields = parseShow(result.stdout);
63
+ return {
64
+ fragmentPath: fields.get('FragmentPath') ?? '',
65
+ loadState: fields.get('LoadState') ?? '',
66
+ };
67
+ };
68
+
69
+ /** `argv` has already been routed to root; canonicalise it and run the read this shape needs. */
70
+ export const elevatedExec = (
71
+ base: HostRunner,
72
+ elevate: Elevate,
73
+ prefixes: readonly string[],
74
+ argv: readonly string[],
75
+ ): Promise<ExecResult> => {
76
+ const canonical = canonicalize(argv);
77
+ const expect = DIR_EXPECT[argv[0] ?? ''];
78
+ if (expect !== undefined) {
79
+ const path = argv[argv.length - 1] ?? '';
80
+ const prefix = prefixOf(path, prefixes);
81
+ return elevate(canonical, {}, async () => {
82
+ // ★ routeExec already required a prefix to route here; this re-derives it rather than
83
+ // trust the caller, so a future routing bug fails the guard instead of skipping it.
84
+ if (prefix === undefined) {
85
+ throw new SudoRefusedError(
86
+ `sshSudoRunner ${path}: not under a declared prefix. Nothing ran as root.`,
87
+ );
88
+ }
89
+ await assertGuardedChain(base, prefix, path, expect);
90
+ });
91
+ }
92
+ if (canonical[0] !== SYSTEMCTL_ABS || canonical[1] === 'daemon-reload') return elevate(canonical);
93
+ const verb = canonical[1] ?? '';
94
+ const unit = canonical[3] ?? '';
95
+ return elevate(canonical, {}, async () => {
96
+ const { fragmentPath, loadState } = await unitStatusOf(base, unit);
97
+ if (loadState === 'masked') {
98
+ throw new SudoRefusedError(
99
+ `sshSudoRunner ${unit}: is masked. Masking is someone's decision, not drift; unmask it ` +
100
+ 'deliberately, then redeploy. Nothing ran as root.',
101
+ );
102
+ }
103
+ if (fragmentPath === '') {
104
+ if (EMPTY_FRAGMENT_OK.has(verb)) return;
105
+ throw new SudoRefusedError(
106
+ `sshSudoRunner ${unit}: has no unit file (FragmentPath empty), and ${verb} is not ` +
107
+ 'stop/disable — refusing rather than run it against whatever systemd currently thinks ' +
108
+ `${unit} is (a kernel-generated unit, say). Nothing ran as root.`,
109
+ );
110
+ }
111
+ if (prefixOf(fragmentPath, prefixes) === undefined) {
112
+ throw new SudoRefusedError(
113
+ `sshSudoRunner ${unit}: FragmentPath ${fragmentPath} is outside every declared prefix ` +
114
+ `(${prefixes.join(', ')}); refusing to touch a unit this runner does not own. Nothing ran as root.`,
115
+ );
116
+ }
117
+ });
118
+ };
@@ -0,0 +1,136 @@
1
+ /**
2
+ * assertGuardedChain against the fake Linux host, unprivileged: a symlink below the prefix, a
3
+ * group-writable directory, a `+` ACL flag, a non-root owner above the prefix, and a directory
4
+ * sitting where a write expects `absent`. No sudo call anywhere in this file.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { fakeSudoHost } from './fake-sudo.ts';
8
+ import { SudoRefusedError } from './sudo-allowlist.ts';
9
+ import { assertGuardedChain } from './sudo-guard.ts';
10
+
11
+ describe('a clean prefix', () => {
12
+ test('a file-or-absent target that is absent is fine', async () => {
13
+ const { base } = fakeSudoHost();
14
+ const target = await assertGuardedChain(
15
+ base,
16
+ '/etc/systemd/system',
17
+ '/etc/systemd/system/thing.service',
18
+ 'file-or-absent',
19
+ );
20
+ expect(target).toBeUndefined();
21
+ });
22
+
23
+ test('mkdir\'s "absent" expectation refuses a directory already there', async () => {
24
+ const { base } = fakeSudoHost({ '/usr/local/bin/sub': 0 });
25
+ await expect(
26
+ assertGuardedChain(base, '/usr/local/bin', '/usr/local/bin/sub', 'absent'),
27
+ ).rejects.toThrow('already exists');
28
+ });
29
+ });
30
+
31
+ describe('a symlink in the chain', () => {
32
+ test('below the prefix is refused, even root-owned', async () => {
33
+ const { base, fake } = fakeSudoHost();
34
+ fake.files.set('/etc/systemd/system/sub', {
35
+ bytes: new Uint8Array(),
36
+ gid: 0,
37
+ kind: 'symlink',
38
+ mode: 0o777,
39
+ uid: 0,
40
+ });
41
+ await expect(
42
+ assertGuardedChain(
43
+ base,
44
+ '/etc/systemd/system',
45
+ '/etc/systemd/system/sub/x.service',
46
+ 'file-or-absent',
47
+ ),
48
+ ).rejects.toBeInstanceOf(SudoRefusedError);
49
+ });
50
+
51
+ test('above the prefix, root-owned, is followed like the merged-/usr symlinks it models', async () => {
52
+ const { base, fake } = fakeSudoHost();
53
+ fake.files.set('/etc', {
54
+ bytes: new Uint8Array(),
55
+ gid: 0,
56
+ kind: 'symlink',
57
+ mode: 0o777,
58
+ uid: 0,
59
+ });
60
+ const target = await assertGuardedChain(
61
+ base,
62
+ '/etc/systemd/system',
63
+ '/etc/systemd/system/thing.service',
64
+ 'file-or-absent',
65
+ );
66
+ expect(target).toBeUndefined();
67
+ });
68
+
69
+ test('above the prefix, owned by someone else, is refused (not treated as the system’s own)', async () => {
70
+ const { base, fake } = fakeSudoHost();
71
+ fake.files.set('/etc', {
72
+ bytes: new Uint8Array(),
73
+ gid: 0,
74
+ kind: 'symlink',
75
+ mode: 0o777,
76
+ uid: 900,
77
+ });
78
+ await expect(
79
+ assertGuardedChain(
80
+ base,
81
+ '/etc/systemd/system',
82
+ '/etc/systemd/system/thing.service',
83
+ 'file-or-absent',
84
+ ),
85
+ ).rejects.toThrow('is a symlink, not a directory');
86
+ });
87
+ });
88
+
89
+ test('a group-writable directory in the chain is refused', async () => {
90
+ const { base, fake } = fakeSudoHost({ '/etc/systemd/system/sub': 0 });
91
+ const live = fake.modes.get('/etc/systemd/system/sub');
92
+ if (live !== undefined) live.mode = 0o775;
93
+ await expect(
94
+ assertGuardedChain(
95
+ base,
96
+ '/etc/systemd/system',
97
+ '/etc/systemd/system/sub/x.service',
98
+ 'file-or-absent',
99
+ ),
100
+ ).rejects.toThrow('writable by group or other');
101
+ });
102
+
103
+ test('an ACL `+` flag anywhere in the chain is refused, fail closed', async () => {
104
+ const { base } = fakeSudoHost({}, new Set(['/etc/systemd/system']));
105
+ await expect(
106
+ assertGuardedChain(
107
+ base,
108
+ '/etc/systemd/system',
109
+ '/etc/systemd/system/thing.service',
110
+ 'file-or-absent',
111
+ ),
112
+ ).rejects.toThrow('POSIX ACL');
113
+ });
114
+
115
+ test('a non-root owner above the prefix is refused', async () => {
116
+ const { base, fake } = fakeSudoHost();
117
+ const live = fake.modes.get('/etc/systemd/system');
118
+ if (live !== undefined) live.uid = 0; // keep the prefix itself root-owned
119
+ fake.dirs.set('/etc', 900);
120
+ fake.modes.set('/etc', { gid: 0, mode: 0o755, uid: 900 });
121
+ await expect(
122
+ assertGuardedChain(
123
+ base,
124
+ '/etc/systemd/system',
125
+ '/etc/systemd/system/thing.service',
126
+ 'file-or-absent',
127
+ ),
128
+ ).rejects.toThrow('not root');
129
+ });
130
+
131
+ test('a directory at a "file" destination is the trap install/rm must not fall into', async () => {
132
+ const { base } = fakeSudoHost({ '/etc/systemd/system/adir': 0 });
133
+ await expect(
134
+ assertGuardedChain(base, '/etc/systemd/system', '/etc/systemd/system/adir', 'file'),
135
+ ).rejects.toThrow('is a directory, not a file');
136
+ });
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The checks sudo-runner.ts makes against the host, as the operator, before it runs a file or
3
+ * directory operation as root. sudo-allowlist.ts checks the argv; this checks what the paths in
4
+ * it actually are — the Linux twin of `../launchd/sudo-guard.ts`, rebuilt on `sudo-listing.ts`'s
5
+ * ONE `ls -ldn` call instead of an lstat per directory, because ssh pays a handshake per call
6
+ * (ssh-runner.ts: no multiplexing unless the caller opts in) where a local syscall pays nothing.
7
+ *
8
+ * ⛔ EVERY DIRECTORY FROM `/` DOWN TO THE FILE MUST BE ROOT'S ALONE, for the same reason the Mac
9
+ * guard insists on it: a directory anyone else may write lets them swap what lies under it — a
10
+ * symlink where the file was — between this read and the privileged call. A root-owned symlink
11
+ * ABOVE the prefix (Debian's merged-`/usr`, `/lib` -> `usr/lib`) is the system's own and is
12
+ * followed, exactly as `aboveDirs`/`betweenDirs` from the Mac guard already encode; a symlink AT
13
+ * or BELOW the prefix is refused outright — a write should never chase one there.
14
+ * ⛔ THE ACL SIGNAL IS THE `+` FLAG ALONE (sudo-listing.ts header): `ls`, not `getfacl`, which is
15
+ * absent on the estate's Debian hosts. A directory that carries one is refused whatever it
16
+ * grants — the guard cannot tell a read-only entry from a write one, so it fails closed.
17
+ */
18
+ import type { FileStat, HostRunner, WriteOptions } from '../launchd/runner.ts';
19
+ import {
20
+ aboveDirs,
21
+ assertReadableBack,
22
+ betweenDirs,
23
+ canRead,
24
+ modeProblem,
25
+ operatorGroups,
26
+ } from '../launchd/sudo-guard.ts';
27
+ import { SudoRefusedError } from './sudo-allowlist.ts';
28
+ import { type Listed, readListing } from './sudo-listing.ts';
29
+
30
+ export { aboveDirs, assertReadableBack, betweenDirs, canRead, modeProblem, operatorGroups };
31
+
32
+ const refuse = (path: string, message: string): Error =>
33
+ new SudoRefusedError(`sshSudoRunner ${path}: ${message}`);
34
+
35
+ const rootOnlyProblem = (entry: Listed | undefined): string | undefined => {
36
+ if (entry === undefined) return 'is missing';
37
+ if (entry.kind !== 'directory') return `is a ${entry.kind}, not a directory`;
38
+ if (entry.uid !== 0) return `is owned by uid ${String(entry.uid)}, not root`;
39
+ if ((entry.mode & 0o022) !== 0) {
40
+ return `is writable by group or other (mode ${entry.mode.toString(8).padStart(4, '0')})`;
41
+ }
42
+ if (entry.acl) return 'carries a POSIX ACL this runner cannot verify the grants of (ls -ldn `+`)';
43
+ return undefined;
44
+ };
45
+
46
+ /** What must be at `path` itself, for the operation about to run there. */
47
+ export type ChainExpect = 'file' | 'file-or-absent' | 'directory' | 'absent';
48
+
49
+ /**
50
+ * ⚠️ NOT PART OF THE `ls -ldn` BATCH, on purpose — mirroring the Mac guard's own
51
+ * `assertPlainPath`, whose ACL check also excludes `path` itself (`assertNoAclGrants(base,
52
+ * path, [...above, prefix, ...dirs])`, never `path` in that list). `readListing`'s
53
+ * `parseListing` requires every requested path to have a line (sudo-listing.ts), so a target
54
+ * that is legitimately ABSENT — the common case for a create — would make the whole batched
55
+ * read look unparseable if it were included. A plain `stat` handles "nothing there" correctly.
56
+ */
57
+ const targetProblem = (entry: FileStat | undefined, expect: ChainExpect): string | undefined => {
58
+ if (expect === 'absent')
59
+ return entry === undefined ? undefined : `already exists (a ${entry.kind})`;
60
+ if (entry === undefined) return expect === 'file-or-absent' ? undefined : 'is missing';
61
+ if (expect === 'file-or-absent')
62
+ return entry.kind === 'file' ? undefined : `is a ${entry.kind}, not a file`;
63
+ return entry.kind === expect ? undefined : `is a ${entry.kind}, not a ${expect}`;
64
+ };
65
+
66
+ /**
67
+ * Refuse a prefix that is not root-only, a symlink or wrong kind between the prefix and `path`,
68
+ * an ACL anywhere in the chain, or a target that is not what `expect` says. ONE `ls -ldn` for the
69
+ * directory chain, plus one plain `stat` for the target (see `targetProblem`'s note on why they
70
+ * are separate calls). ⚠️ Reads only, as the operator — so `checkWrite` runs this at plan time too.
71
+ */
72
+ export const assertGuardedChain = async (
73
+ base: HostRunner,
74
+ prefix: string,
75
+ path: string,
76
+ expect: ChainExpect,
77
+ ): Promise<FileStat | undefined> => {
78
+ const above = aboveDirs(prefix);
79
+ const between = betweenDirs(prefix, path);
80
+ const chain = [...above, prefix, ...between];
81
+ const listed = await readListing(base, '/usr/bin/ls', path, chain);
82
+ for (const dir of above) {
83
+ const entry = listed.get(dir);
84
+ // ★ A root-owned symlink ABOVE the prefix is the system's own and is followed, as every path
85
+ // lookup does; a symlink anywhere else in the chain is refused by rootOnlyProblem below.
86
+ const problem =
87
+ entry?.kind === 'symlink' && entry.uid === 0 ? undefined : rootOnlyProblem(entry);
88
+ if (problem !== undefined) {
89
+ throw refuse(
90
+ path,
91
+ `${dir}, above the declared prefix ${prefix}, ${problem}. Whoever may change it may swap ` +
92
+ 'the prefix itself; every directory from / down must be root-only. Nothing ran as root.',
93
+ );
94
+ }
95
+ }
96
+ const prefixProblem = rootOnlyProblem(listed.get(prefix));
97
+ if (prefixProblem !== undefined) {
98
+ throw refuse(
99
+ path,
100
+ `the declared prefix ${prefix} ${prefixProblem}. Declare only a real directory that root ` +
101
+ 'owns and only root may write. Nothing ran as root.',
102
+ );
103
+ }
104
+ for (const dir of between) {
105
+ const problem = rootOnlyProblem(listed.get(dir));
106
+ if (problem !== undefined) {
107
+ throw refuse(
108
+ path,
109
+ `${dir} ${problem}. Every directory from the prefix ${prefix} down to the file must be ` +
110
+ 'one that root owns and only root may write. Nothing ran as root.',
111
+ );
112
+ }
113
+ }
114
+ const target = await base.stat(path);
115
+ const problem = targetProblem(target, expect);
116
+ if (problem !== undefined) throw refuse(path, `${problem}. Nothing ran as root.`);
117
+ return target;
118
+ };
119
+
120
+ /**
121
+ * The mode/owner and read-back checks for a file write under `prefix`, plus the path chain.
122
+ * ★ Reads only, as the operator — the same checks run at plan time (`checkWrite`) and before the
123
+ * install/mv pair. `expect` is `'file-or-absent'`: a write may create or replace.
124
+ */
125
+ export const assertInstallable = async (
126
+ base: HostRunner,
127
+ prefix: string,
128
+ path: string,
129
+ options: WriteOptions,
130
+ groups: readonly number[] | undefined,
131
+ ): Promise<void> => {
132
+ const mode = modeProblem(options);
133
+ if (mode !== undefined) {
134
+ throw refuse(
135
+ path,
136
+ `${mode}. Declare a mode without those bits (0644, 0640, 0755), or give the file to its ` +
137
+ 'real owner. Nothing ran as root.',
138
+ );
139
+ }
140
+ await assertGuardedChain(base, prefix, path, 'file-or-absent');
141
+ await assertReadableBack(base, path, options, groups);
142
+ };
@@ -0,0 +1,151 @@
1
+ /**
2
+ * `Systemd.Unit` and `Remote.File`'s own lifecycle functions, driven through sshSudoRunner over
3
+ * the fake Linux host — proving the two families this runner exists for actually work end to end,
4
+ * not just that the runner's own primitives do. No sudo runs anywhere but through the fake.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { fakeSudoHost } from './fake-sudo.ts';
8
+ import { SudoRefusedError } from './sudo-runner.ts';
9
+ import { deleteFile, reconcileFile } from './remote-file-lifecycle.ts';
10
+ import type { RemoteFileProps } from './remote-file-form.ts';
11
+ import { deleteUnit, diffUnit, reconcileUnit } from './unit-lifecycle.ts';
12
+ import type { SystemdUnitProps } from './unit-form.ts';
13
+
14
+ const UNIT: SystemdUnitProps = {
15
+ name: 'hf-thing.service',
16
+ sections: [
17
+ { lines: [['Description', 'a thing']], name: 'Unit' },
18
+ { lines: [['ExecStart', '/usr/local/bin/hf-thing']], name: 'Service' },
19
+ { lines: [['WantedBy', 'multi-user.target']], name: 'Install' },
20
+ ],
21
+ };
22
+
23
+ describe('Systemd.Unit through sshSudoRunner', () => {
24
+ test('create writes, reloads, enables and starts — all elevated', async () => {
25
+ const { privileged, runner } = fakeSudoHost();
26
+ const attrs = await reconcileUnit(runner, UNIT, undefined);
27
+ expect(attrs.active).toBe(true);
28
+ expect(attrs.enabled).toBe(true);
29
+ const verbs = privileged().map((call) => call[0]);
30
+ expect(verbs.filter((v) => v === 'systemctl')).toEqual([]); // canonicalised to the absolute path
31
+ expect(verbs).toContain('/usr/bin/systemctl');
32
+ // write (install+mv), daemon-reload, enable, start.
33
+ expect(privileged().filter((c) => c[0] === '/usr/bin/install')).toHaveLength(1);
34
+ expect(
35
+ privileged().some((c) => c[0] === '/usr/bin/systemctl' && c[1] === 'daemon-reload'),
36
+ ).toBe(true);
37
+ expect(privileged().some((c) => c[1] === 'enable')).toBe(true);
38
+ expect(privileged().some((c) => c[1] === 'start')).toBe(true);
39
+ });
40
+
41
+ test('an unchanged unit is reconciled again with zero privileged calls', async () => {
42
+ const { privileged, runner } = fakeSudoHost();
43
+ const created = await reconcileUnit(runner, UNIT, undefined);
44
+ expect(await diffUnit(runner, UNIT, created)).toEqual({ action: 'noop' });
45
+ const before = privileged().length;
46
+ const again = await reconcileUnit(runner, UNIT, created);
47
+ expect(privileged().length).toBe(before); // ★ no restart, no reload — the whole point.
48
+ expect(again.active).toBe(true);
49
+ });
50
+
51
+ test('a restartOn digest change updates and restarts, via elevated calls only', async () => {
52
+ const { privileged, runner } = fakeSudoHost();
53
+ const created = await reconcileUnit(runner, UNIT, undefined);
54
+ const changed: SystemdUnitProps = { ...UNIT, restartOn: ['new-digest'] };
55
+ expect((await diffUnit(runner, changed, created))?.action).toBe('update');
56
+ const before = privileged().length;
57
+ const updated = await reconcileUnit(runner, changed, created);
58
+ expect(privileged().length).toBeGreaterThan(before);
59
+ expect(updated.configSha256).not.toBe(created.configSha256);
60
+ });
61
+
62
+ test('delete stops, disables, removes the file, reloads — all elevated', async () => {
63
+ const { fake, privileged, runner } = fakeSudoHost();
64
+ const created = await reconcileUnit(runner, UNIT, undefined);
65
+ await deleteUnit(runner, created);
66
+ expect(fake.files.has(created.unitPath)).toBe(false);
67
+ const verbs = privileged()
68
+ .filter((c) => c[0] === '/usr/bin/systemctl')
69
+ .map((c) => c[1]);
70
+ expect(verbs).toContain('stop');
71
+ expect(verbs).toContain('disable');
72
+ expect(verbs).toContain('daemon-reload');
73
+ expect(privileged().some((c) => c[0] === '/usr/bin/rm')).toBe(true);
74
+ });
75
+
76
+ test('a vendor unit outside every prefix is unreachable, refused before sudo runs', async () => {
77
+ const { fake, privileged, runner } = fakeSudoHost();
78
+ fake.placeUnit('/usr/lib/systemd/system/pveproxy.service', '[Service]\nExecStart=/x\n', {
79
+ active: true,
80
+ enabled: true,
81
+ });
82
+ await expect(runner.exec(['systemctl', 'restart', '--', 'pveproxy.service'])).rejects.toThrow(
83
+ SudoRefusedError,
84
+ );
85
+ await expect(runner.exec(['systemctl', 'restart', '--', 'pveproxy.service'])).rejects.toThrow(
86
+ 'FragmentPath',
87
+ );
88
+ expect(privileged()).toEqual([]);
89
+ });
90
+
91
+ // 🔴 Adversarial review, round 2: a masked vendor unit also reads FragmentPath empty, which
92
+ // used to fall into the "no file, elevate anyway" exemption regardless of verb.
93
+ test('a masked unit is refused, even one this runner’s own prefix could otherwise reach', async () => {
94
+ const { fake, privileged, runner } = fakeSudoHost();
95
+ fake.placeUnit('/etc/systemd/system/pveproxy.service', '[Service]\nExecStart=/x\n', {
96
+ active: false,
97
+ enabled: false,
98
+ masked: true,
99
+ });
100
+ for (const verb of ['enable', 'start', 'stop', 'disable', 'restart']) {
101
+ await expect(runner.exec(['systemctl', verb, '--', 'pveproxy.service'])).rejects.toThrow(
102
+ 'is masked',
103
+ );
104
+ }
105
+ expect(privileged()).toEqual([]);
106
+ });
107
+
108
+ // 🔴 Adversarial review, round 2: the empty-FragmentPath exemption used to admit every write
109
+ // verb, not just the two `deleteUnit` actually needs — so a kernel-generated pseudo-unit like
110
+ // `init.scope` (empty FragmentPath, never file-backed) could still elevate enable/start/restart.
111
+ test('a unit with no file at all elevates stop/disable but refuses enable/start/restart', async () => {
112
+ const { privileged, runner } = fakeSudoHost();
113
+ for (const verb of ['enable', 'start', 'restart']) {
114
+ await expect(runner.exec(['systemctl', verb, '--', 'init.scope'])).rejects.toThrow(
115
+ 'is not stop/disable',
116
+ );
117
+ }
118
+ expect(privileged()).toEqual([]);
119
+ // stop/disable of a genuinely unknown unit still REACHES systemctl as root (which itself
120
+ // then judges it, exit 0 or not) — this runner's job is only to decide whether to ASK as
121
+ // root, not to pre-judge every unit name that might exist.
122
+ await runner.exec(['systemctl', 'stop', '--', 'init.scope']);
123
+ expect(privileged().some((c) => c[0] === '/usr/bin/systemctl' && c[1] === 'stop')).toBe(true);
124
+ });
125
+ });
126
+
127
+ const SCRIPT: RemoteFileProps = {
128
+ content: '#!/bin/sh\necho hi\n',
129
+ mode: 0o755,
130
+ path: '/usr/local/bin/hf-thing',
131
+ };
132
+
133
+ describe('Remote.File through sshSudoRunner', () => {
134
+ test('create, update and delete, all through install/mv/rm', async () => {
135
+ const { fake, privileged, runner } = fakeSudoHost();
136
+ const created = await reconcileFile(runner, SCRIPT);
137
+ expect(fake.files.get(SCRIPT.path)?.mode).toBe(0o755);
138
+ expect(privileged().map((c) => c[0])).toEqual(['/usr/bin/install', '/usr/bin/mv']);
139
+
140
+ const updated = await reconcileFile(
141
+ runner,
142
+ { ...SCRIPT, content: '#!/bin/sh\necho bye\n' },
143
+ created,
144
+ );
145
+ expect(new TextDecoder().decode(fake.files.get(SCRIPT.path)?.bytes)).toContain('bye');
146
+
147
+ await deleteFile(runner, updated);
148
+ expect(fake.files.has(SCRIPT.path)).toBe(false);
149
+ expect(privileged().some((c) => c[0] === '/usr/bin/rm' && c.at(-1) === SCRIPT.path)).toBe(true);
150
+ });
151
+ });