@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,111 @@
1
+ /**
2
+ * The mode/owner and read-back half of sudo-guard.ts, split out to keep that file under the
3
+ * house's 250-line cap. `assertPlainPath` (path chain) stays there; this is "is the mode/owner
4
+ * safe, and can the deploying user read the result back" — re-exported from sudo-guard.ts so
5
+ * nothing importing from there has to know this file exists.
6
+ */
7
+ import type { HostRunner, WriteOptions } from './runner.ts';
8
+ import { SudoRefusedError } from './sudo-allowlist.ts';
9
+
10
+ const refuse = (path: string, message: string): Error =>
11
+ new SudoRefusedError(`sudoRunner ${path}: ${message}`);
12
+
13
+ /**
14
+ * ⛔ A ROOT-OWNED FILE UNDER A PREFIX IS ROOT'S TO CHANGE, AND ONLY ROOT'S (decided 2026-09-21).
15
+ * Group- or world-writable, anyone in that class rewrites a file root installed — a daemon's
16
+ * config, a script a LaunchDaemon runs as root — which is root by another name. Setuid or setgid,
17
+ * it runs as root (or its group) for whoever executes it. install(1) would apply either, as root.
18
+ * ★ Root-owned is an omitted owner (the runner installs as root under a prefix) or uid 0. A file
19
+ * handed to another user is that user's to change; the sticky bit changes nothing on a file.
20
+ * 🔴 MEASURED (adversarial review, 2026-09-23, round 1): "handed to another user" checked only
21
+ * `uid`, never `gid`. `{ uid: 501, gid: 0, mode: 0o2775 }` — a setgid file owned by an
22
+ * unprivileged uid but GROUPED to root — passed untouched, because a non-zero uid alone skipped
23
+ * every check below. Fixed by also treating `gid: 0` as root-adjacent.
24
+ * 🔴 MEASURED (adversarial review, round 2, same day): the round-1 fix still missed an OMITTED
25
+ * `gid`. `remote-file-plan.ts`'s `identity()` returns no `gid` key at all when a `RemoteFile`
26
+ * declares no `group`, so `{ uid: 501, mode: 0o2775 }` reaches this check with `gid: undefined`
27
+ * — neither `0` nor genuinely someone else's. `sudo-write.ts` then omits `-g` from `install`
28
+ * entirely, and GNU `install`, run as root via `sudo -n`, defaults an omitted `-g` to the
29
+ * INVOKING PROCESS's group — root's own — whenever the destination directory is not itself
30
+ * setgid (which none of this runner's declared prefixes are, measured in linux-host-measured.md).
31
+ * The file lands exactly as `{ uid: 501, gid: 0, mode: 0o2775 }` — the identical escalation the
32
+ * round-1 fix closed, reached by OMISSION instead of an explicit `group: 0`. So "root-adjacent"
33
+ * now also means an omitted `gid`: only a file with a genuinely non-root, EXPLICITLY GIVEN owner
34
+ * AND group is someone else's alone to make setuid/setgid or writable — an omission is never
35
+ * read as a safe default on either side, uid or gid.
36
+ */
37
+ export const modeProblem = (options: WriteOptions): string | undefined => {
38
+ const rootAdjacent =
39
+ options.uid === undefined ||
40
+ options.uid === 0 ||
41
+ options.gid === undefined ||
42
+ options.gid === 0;
43
+ if (!rootAdjacent) return undefined;
44
+ const found: string[] = [];
45
+ if ((options.mode & 0o6000) !== 0) found.push('setuid/setgid');
46
+ if ((options.mode & 0o022) !== 0) found.push('writable by group or other');
47
+ if (found.length === 0) return undefined;
48
+ return `mode ${options.mode.toString(8).padStart(4, '0')} is ${found.join(' and ')} on a root-owned file`;
49
+ };
50
+
51
+ /**
52
+ * POSIX read permission for one uid: owner class, else group class, else other. `undefined` when
53
+ * only group membership could decide and the groups are unknown.
54
+ * ⚠️ EXACTLY ONE CLASS APPLIES. A group member gets the group bits even when the other bits are
55
+ * wider (`0604` hides a file from its group), so "other can read" is not enough on its own.
56
+ */
57
+ export const canRead = (
58
+ file: { readonly uid: number; readonly gid: number; readonly mode: number },
59
+ uid: number,
60
+ groups: readonly number[] | undefined,
61
+ ): boolean | undefined => {
62
+ if (uid === 0) return true;
63
+ if (file.uid === uid) return (file.mode & 0o400) !== 0;
64
+ const groupRead = (file.mode & 0o040) !== 0;
65
+ const otherRead = (file.mode & 0o004) !== 0;
66
+ // Membership cannot change the answer when both classes agree.
67
+ if (groupRead === otherRead) return groupRead;
68
+ if (groups === undefined) return undefined;
69
+ return groups.includes(file.gid) ? groupRead : otherRead;
70
+ };
71
+
72
+ /**
73
+ * ⛔ REFUSE A FILE THE DEPLOYING USER COULD NOT READ BACK, before writing it. Both providers read
74
+ * every write back to verify it (host-file-lifecycle.ts, job-lifecycle.ts) and read it again at
75
+ * every plan, as the deploying user; this runner elevates writes, never reads. A root-only
76
+ * `0600` would be installed, then fail the read-back with EACCES and stay on disk with no state,
77
+ * so every later plan would fail too.
78
+ * ★ An omitted owner is root (under a prefix the runner writes as root). An omitted group is the
79
+ * parent directory's, which is the group a new file gets on macOS.
80
+ * ⚠️ Unknown groups never refuse: the read-back after the write stays the final judge.
81
+ */
82
+ export const assertReadableBack = async (
83
+ base: HostRunner,
84
+ path: string,
85
+ options: WriteOptions,
86
+ groups: readonly number[] | undefined,
87
+ ): Promise<void> => {
88
+ const operator = base.effectiveUid();
89
+ const parent = path.slice(0, path.lastIndexOf('/')) || '/';
90
+ const gid = options.gid ?? (await base.stat(parent))?.gid ?? 0;
91
+ const file = { gid, mode: options.mode, uid: options.uid ?? 0 };
92
+ if (canRead(file, operator, groups) !== false) return;
93
+ throw refuse(
94
+ path,
95
+ `owner ${String(file.uid)}, group ${String(gid)}, mode ${file.mode.toString(8)} would hide it ` +
96
+ `from the deploying user (uid ${String(operator)}), who reads every file back. Declare a ` +
97
+ 'mode that user can read, or deploy as root. Nothing was written.',
98
+ );
99
+ };
100
+
101
+ /**
102
+ * The deploying user's groups, from `id -G`; `undefined` when that fails.
103
+ * ⚠️ NOT process.getgroups(). MEASURED 2026-09-21 on macOS 27.2: getgroups() returned 16 groups
104
+ * where `id -G` returned 18, because macOS caps a process's own list at 16. A group past the
105
+ * 16th would read as "not a member" and refuse a file the user can in fact read.
106
+ */
107
+ export const operatorGroups = async (base: HostRunner): Promise<readonly number[] | undefined> => {
108
+ const result = await base.exec(['/usr/bin/id', '-G']);
109
+ const ids = result.stdout.trim().split(/\s+/);
110
+ return result.exitCode === 0 && ids.every((id) => /^\d+$/.test(id)) ? ids.map(Number) : undefined;
111
+ };
@@ -23,6 +23,9 @@
23
23
  import type { FileStat, HostRunner, WriteOptions } from './runner.ts';
24
24
  import { assertNoAclGrants } from './sudo-acl.ts';
25
25
  import { SudoRefusedError } from './sudo-allowlist.ts';
26
+ import { assertReadableBack, modeProblem } from './sudo-guard-mode.ts';
27
+
28
+ export { assertReadableBack, canRead, modeProblem, operatorGroups } from './sudo-guard-mode.ts';
26
29
 
27
30
  const refuse = (path: string, message: string): Error =>
28
31
  new SudoRefusedError(`sudoRunner ${path}: ${message}`);
@@ -126,23 +129,6 @@ export const assertPlainPath = async (
126
129
  throw refuse(path, `is ${target === undefined ? 'missing' : `a ${target.kind}`}, not a file`);
127
130
  };
128
131
 
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
132
  /**
147
133
  * Every check a write under `prefix` meets before anything is staged or run as root: the mode, the
148
134
  * path (assertPlainPath) and the read-back (assertReadableBack). ★ Reads only, as the deploying
@@ -166,65 +152,3 @@ export const assertInstallable = async (
166
152
  await assertPlainPath(base, prefix, path, 'file-or-absent');
167
153
  await assertReadableBack(base, path, options, groups);
168
154
  };
169
-
170
- /**
171
- * POSIX read permission for one uid: owner class, else group class, else other. `undefined` when
172
- * only group membership could decide and the groups are unknown.
173
- * ⚠️ EXACTLY ONE CLASS APPLIES. A group member gets the group bits even when the other bits are
174
- * wider (`0604` hides a file from its group), so "other can read" is not enough on its own.
175
- */
176
- export const canRead = (
177
- file: { readonly uid: number; readonly gid: number; readonly mode: number },
178
- uid: number,
179
- groups: readonly number[] | undefined,
180
- ): boolean | undefined => {
181
- if (uid === 0) return true;
182
- if (file.uid === uid) return (file.mode & 0o400) !== 0;
183
- const groupRead = (file.mode & 0o040) !== 0;
184
- const otherRead = (file.mode & 0o004) !== 0;
185
- // Membership cannot change the answer when both classes agree.
186
- if (groupRead === otherRead) return groupRead;
187
- if (groups === undefined) return undefined;
188
- return groups.includes(file.gid) ? groupRead : otherRead;
189
- };
190
-
191
- /**
192
- * ⛔ REFUSE A FILE THE DEPLOYING USER COULD NOT READ BACK, before writing it. Both providers read
193
- * every write back to verify it (host-file-lifecycle.ts, job-lifecycle.ts) and read it again at
194
- * every plan, as the deploying user; this runner elevates writes, never reads. A root-only
195
- * `0600` would be installed, then fail the read-back with EACCES and stay on disk with no state,
196
- * so every later plan would fail too.
197
- * ★ An omitted owner is root (under a prefix the runner writes as root). An omitted group is the
198
- * parent directory's, which is the group a new file gets on macOS.
199
- * ⚠️ Unknown groups never refuse: the read-back after the write stays the final judge.
200
- */
201
- export const assertReadableBack = async (
202
- base: HostRunner,
203
- path: string,
204
- options: WriteOptions,
205
- groups: readonly number[] | undefined,
206
- ): Promise<void> => {
207
- const operator = base.effectiveUid();
208
- const parent = path.slice(0, path.lastIndexOf('/')) || '/';
209
- const gid = options.gid ?? (await base.stat(parent))?.gid ?? 0;
210
- const file = { gid, mode: options.mode, uid: options.uid ?? 0 };
211
- if (canRead(file, operator, groups) !== false) return;
212
- throw refuse(
213
- path,
214
- `owner ${String(file.uid)}, group ${String(gid)}, mode ${file.mode.toString(8)} would hide it ` +
215
- `from the deploying user (uid ${String(operator)}), who reads every file back. Declare a ` +
216
- 'mode that user can read, or deploy as root. Nothing was written.',
217
- );
218
- };
219
-
220
- /**
221
- * The deploying user's groups, from `id -G`; `undefined` when that fails.
222
- * ⚠️ NOT process.getgroups(). MEASURED 2026-09-21 on macOS 27.2: getgroups() returned 16 groups
223
- * where `id -G` returned 18, because macOS caps a process's own list at 16. A group past the
224
- * 16th would read as "not a member" and refuse a file the user can in fact read.
225
- */
226
- export const operatorGroups = async (base: HostRunner): Promise<readonly number[] | undefined> => {
227
- const result = await base.exec(['/usr/bin/id', '-G']);
228
- const ids = result.stdout.trim().split(/\s+/);
229
- return result.exitCode === 0 && ids.every((id) => /^\d+$/.test(id)) ? ids.map(Number) : undefined;
230
- };
@@ -34,7 +34,44 @@ describe('modeProblem', () => {
34
34
  ['4755 root is setuid', { mode: 0o4755 }, 'setuid/setgid'],
35
35
  ['2755 root is setgid', { mode: 0o2755, uid: 0 }, 'setuid/setgid'],
36
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],
37
+ [
38
+ '0664 handed to another user AND an explicitly non-root group is theirs to change',
39
+ { gid: 80, mode: 0o664, uid: OPERATOR },
40
+ undefined,
41
+ ],
42
+ // 🔴 Adversarial review, 2026-09-23, round 1: the non-root-uid exemption alone let a
43
+ // setgid-root file through untouched as long as its OWNER was merely non-zero — the group is
44
+ // root's just as much as the owner being uid 0 would be. gid 0 must keep every check active.
45
+ [
46
+ 'setgid to root’s own group, handed to another OWNER, is still root’s file to protect',
47
+ { gid: 0, mode: 0o2775, uid: OPERATOR },
48
+ 'setuid/setgid',
49
+ ],
50
+ [
51
+ 'group-writable with root’s own group, handed to another owner',
52
+ { gid: 0, mode: 0o664, uid: OPERATOR },
53
+ 'writable by group or other',
54
+ ],
55
+ [
56
+ 'handed to another owner AND another group is genuinely theirs, setgid included',
57
+ { gid: 80, mode: 0o2775, uid: OPERATOR },
58
+ undefined,
59
+ ],
60
+ // 🔴 Adversarial review, round 2: an OMITTED gid is not "someone else's" either — GNU install,
61
+ // run as root via sudo, defaults a missing -g to root's OWN group whenever the destination
62
+ // directory is not itself setgid (none of this runner's prefixes are), so a `RemoteFile`
63
+ // with an owner but no declared `group` reaches this check exactly as `gid: 0` would, and
64
+ // must be refused exactly as `gid: 0` is above.
65
+ [
66
+ 'setgid, handed to another owner, with the group simply never declared',
67
+ { mode: 0o2775, uid: OPERATOR },
68
+ 'setuid/setgid',
69
+ ],
70
+ [
71
+ 'group-writable, handed to another owner, with the group simply never declared',
72
+ { mode: 0o664, uid: OPERATOR },
73
+ 'writable by group or other',
74
+ ],
38
75
  ] as const)('%s', (_name, options, found) => {
39
76
  const problem = modeProblem(options);
40
77
  if (found === undefined) expect(problem).toBeUndefined();
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The "as root" half of fake-sudo.ts: what each allowlisted program does against the SAME
3
+ * in-memory host `fake-linux-host.ts` builds, once `/usr/bin/sudo` has decided to run it.
4
+ *
5
+ * ⛔ TEST-ONLY, like fake-linux-host.ts: no provider imports it, it is not on the barrel.
6
+ * ★ CHOWN AND MKDIR ARE HANDLED HERE, NOT BY DELEGATING TO `fake.runner.exec()` — the fake's own
7
+ * `chown` refuses when its FIXED `root` flag is false (which it is: the base host models an
8
+ * unprivileged operator), and `mkdir`/`chmod`/`rmdir` do not check ownership at all. Only a
9
+ * handler that writes `fake.dirs` / `fake.modes` / `fake.files` directly proves the runner's
10
+ * OWN allowlist and guard are what gate root, not a permission check the fake happens to have.
11
+ * ★ INSTALL AND MV READ AND WRITE `fake.files` DIRECTLY TOO, because the staged file the operator
12
+ * wrote (via `stageRemote`'s ordinary `writeFileAtomic`) already lives there — there is no
13
+ * separate "staged bytes" side-channel to model, unlike the Mac fake's local temp files.
14
+ */
15
+ import type { ExecResult, FileStat } from '../launchd/runner.ts';
16
+ import { CHMOD, CHOWN, INSTALL, MKDIR, MV, RM, RMDIR, SYSTEMCTL_ABS } from './sudo-allowlist.ts';
17
+ import type { fakeLinuxHost } from './fake-linux-host.ts';
18
+
19
+ type Fake = ReturnType<typeof fakeLinuxHost>;
20
+
21
+ /** What a test can force `sudo`'s next `mv` / `rm` to answer instead of actually running it. */
22
+ export type RootState = { mvFailure?: ExecResult; rmFailure?: ExecResult };
23
+
24
+ const ok = (stdout = ''): ExecResult => ({ exitCode: 0, stderr: '', stdout });
25
+ const fail = (exitCode: number, stderr: string): ExecResult => ({ exitCode, stderr, stdout: '' });
26
+ const parent = (path: string): string => path.slice(0, path.lastIndexOf('/')) || '/';
27
+
28
+ const applyOwner = (live: { uid: number; gid: number }, owner: string): void => {
29
+ const [uidPart, gidPart] = owner.includes(':') ? owner.split(':') : [owner, undefined];
30
+ if (uidPart !== undefined && uidPart !== '') live.uid = Number(uidPart);
31
+ if (gidPart !== undefined && gidPart !== '') live.gid = Number(gidPart);
32
+ };
33
+
34
+ const dirRoot = (fake: Fake, argv: readonly string[]): ExecResult | undefined => {
35
+ const [program, ...args] = argv;
36
+ if (program === MKDIR) {
37
+ const [, mode, , path] = args;
38
+ if (path === undefined) return fail(1, 'mkdir: missing operand');
39
+ fake.dirs.set(path, 0);
40
+ fake.modes.set(path, { gid: 0, mode: Number.parseInt(mode ?? '755', 8), uid: 0 });
41
+ return ok();
42
+ }
43
+ if (program === CHMOD) {
44
+ const [mode, , path] = args;
45
+ const live = path === undefined ? undefined : fake.modes.get(path);
46
+ if (live === undefined) return fail(1, `chmod: ${String(path)}: No such file or directory`);
47
+ live.mode = Number.parseInt(mode ?? '755', 8);
48
+ return ok();
49
+ }
50
+ if (program === RMDIR) {
51
+ const [, path] = args;
52
+ if (path === undefined || !fake.dirs.has(path)) {
53
+ return fail(1, `rmdir: '${String(path)}': No such file or directory`);
54
+ }
55
+ if ([...fake.files.keys()].some((file) => parent(file) === path)) {
56
+ return fail(1, `rmdir: failed to remove '${path}': Directory not empty`);
57
+ }
58
+ fake.dirs.delete(path);
59
+ fake.modes.delete(path);
60
+ return ok();
61
+ }
62
+ return undefined;
63
+ };
64
+
65
+ const fileRoot = (
66
+ fake: Fake,
67
+ argv: readonly string[],
68
+ state: RootState,
69
+ ): ExecResult | undefined => {
70
+ const [program, ...args] = argv;
71
+ if (program === INSTALL) {
72
+ const tIndex = args.indexOf('-T');
73
+ const [source, dest] = args.slice(-2);
74
+ const ids = args.slice(2, tIndex);
75
+ const owned = ids.includes('-o') ? Number(ids[ids.indexOf('-o') + 1]) : 0;
76
+ const grouped = ids.includes('-g') ? Number(ids[ids.indexOf('-g') + 1]) : 0;
77
+ const bytes = source === undefined ? undefined : fake.files.get(source)?.bytes;
78
+ if (bytes === undefined || dest === undefined) {
79
+ return fail(1, `install: cannot stat '${String(source)}': No such file or directory`);
80
+ }
81
+ const kind: FileStat['kind'] = 'file';
82
+ fake.files.set(dest, {
83
+ bytes,
84
+ gid: grouped,
85
+ kind,
86
+ mode: Number.parseInt(args[1] ?? '644', 8),
87
+ uid: owned,
88
+ });
89
+ return ok();
90
+ }
91
+ if (program === MV) {
92
+ if (state.mvFailure !== undefined) {
93
+ const failure = state.mvFailure;
94
+ delete state.mvFailure; // ★ once, like a real transient failure — not every mv after it
95
+ return failure;
96
+ }
97
+ const [source, dest] = args.slice(-2);
98
+ const entry = source === undefined ? undefined : fake.files.get(source);
99
+ if (entry === undefined || dest === undefined) {
100
+ return fail(1, `mv: cannot stat '${String(source)}': No such file or directory`);
101
+ }
102
+ fake.files.delete(source ?? '');
103
+ fake.files.set(dest, entry);
104
+ return ok();
105
+ }
106
+ if (program === RM) {
107
+ if (state.rmFailure !== undefined) {
108
+ const failure = state.rmFailure;
109
+ delete state.rmFailure; // ★ once, like a real transient failure
110
+ return failure;
111
+ }
112
+ const [, , path] = args;
113
+ if (path !== undefined) fake.files.delete(path);
114
+ return ok();
115
+ }
116
+ return undefined;
117
+ };
118
+
119
+ /**
120
+ * `chown` on either a directory (`fake.modes`) or a file (`fake.files` — the derived temp,
121
+ * `sudo-write.ts`'s own non-root-owner step). `applyOwner` accepts the `+`-forced spec
122
+ * unchanged: `Number('+501')` parses to `501` in JS, same as a bare digit string.
123
+ */
124
+ const chownRoot = (fake: Fake, argv: readonly string[]): ExecResult | undefined => {
125
+ if (argv[0] !== CHOWN) return undefined;
126
+ const [owner, , path] = argv.slice(1);
127
+ if (owner === undefined || path === undefined) {
128
+ return fail(1, `chown: ${String(path)}: No such file or directory`);
129
+ }
130
+ const dir = fake.modes.get(path);
131
+ if (dir !== undefined) {
132
+ applyOwner(dir, owner);
133
+ return ok();
134
+ }
135
+ const file = fake.files.get(path);
136
+ if (file !== undefined) {
137
+ applyOwner(file, owner);
138
+ return ok();
139
+ }
140
+ return fail(1, `chown: ${path}: No such file or directory`);
141
+ };
142
+
143
+ /** Everything `/usr/bin/sudo -n --` may run, dispatched as root against the same fake host. */
144
+ export const asRoot = async (
145
+ fake: Fake,
146
+ argv: readonly string[],
147
+ state: RootState = {},
148
+ ): Promise<ExecResult> => {
149
+ const chown = chownRoot(fake, argv);
150
+ if (chown !== undefined) return chown;
151
+ const dir = dirRoot(fake, argv);
152
+ if (dir !== undefined) return dir;
153
+ const file = fileRoot(fake, argv, state);
154
+ if (file !== undefined) return file;
155
+ if (argv[0] === SYSTEMCTL_ABS) return fake.runner.exec(['systemctl', ...argv.slice(1)]);
156
+ return fail(1, `sudo: ${String(argv[0])}: command not found`);
157
+ };
@@ -0,0 +1,142 @@
1
+ /**
2
+ * sshSudoRunner over fake-linux-host.ts, for the tests beside it: the operator is uid 1000 and
3
+ * unprivileged, and `/usr/bin/sudo` is a tiny interpreter (fake-sudo-root.ts) that runs the
4
+ * allowlisted programs against the SAME in-memory host, as root. No test anywhere runs ssh or a
5
+ * real sudo.
6
+ *
7
+ * ⛔ TEST-ONLY, like fake-linux-host.ts: no provider imports it, it is not on the barrel.
8
+ * ★ `ls -ldn` AND `mktemp -d` ARE MODELLED HERE, AS THE OPERATOR — sudo-guard.ts's chain read and
9
+ * sudo-stage.ts's staging both run unprivileged, so both need a host that answers them without
10
+ * sudo. `ls` reads through `fake.runner.stat()` (already correct for declared dirs, their
11
+ * implied root-owned ancestors, and files) and formats one `ls -ldn`-shaped line per path;
12
+ * `mktemp -d` registers a fresh 0700 directory the operator owns.
13
+ * ★ SUDO REFUSES A CALL THAT WAS NOT LOGGED FIRST, so every test holds the runner to "logged
14
+ * before it runs", exactly as the Mac fake does.
15
+ */
16
+ import type { ExecResult, FileStat, HostRunner } from '../launchd/runner.ts';
17
+ import { SUDO_SAYS } from '../launchd/fake-sudo.ts';
18
+ import { fakeLinuxHost } from './fake-linux-host.ts';
19
+ import { type RootState, asRoot } from './fake-sudo-root.ts';
20
+ import { SUDO } from './sudo-allowlist.ts';
21
+ import { makeSshSudoRunner } from './sudo-runner.ts';
22
+ import { stageRemote } from './sudo-stage.ts';
23
+
24
+ export { SUDO_SAYS } from '../launchd/fake-sudo.ts';
25
+
26
+ export const OPERATOR = 1000;
27
+ export const PREFIXES = ['/etc/systemd/system', '/usr/local/bin'];
28
+
29
+ const ok = (stdout = ''): ExecResult => ({ exitCode: 0, stderr: '', stdout });
30
+ const fail = (exitCode: number, stderr: string): ExecResult => ({ exitCode, stderr, stdout: '' });
31
+
32
+ const permString = (mode: number): string => {
33
+ const triplet = (shift: number, special: number, ch: string, upper: string) => {
34
+ const r = (mode & (0o4 << shift)) === 0 ? '-' : 'r';
35
+ const w = (mode & (0o2 << shift)) === 0 ? '-' : 'w';
36
+ const exec = (mode & (0o1 << shift)) !== 0;
37
+ const marked = (mode & special) !== 0;
38
+ return `${r}${w}${marked ? (exec ? ch : upper) : exec ? 'x' : '-'}`;
39
+ };
40
+ return `${triplet(6, 0o4000, 's', 'S')}${triplet(3, 0o2000, 's', 'S')}${triplet(0, 0o1000, 't', 'T')}`;
41
+ };
42
+
43
+ const lsLine = (path: string, stat: FileStat, acl: boolean): string => {
44
+ const kind = stat.kind === 'directory' ? 'd' : stat.kind === 'symlink' ? 'l' : '-';
45
+ const target = stat.kind === 'symlink' ? ` -> ${path}.target` : '';
46
+ return (
47
+ `${kind}${permString(stat.mode)}${acl ? '+' : ''} 1 ${String(stat.uid)} ${String(stat.gid)} ` +
48
+ `${String(stat.size)} Sep 22 10:12 ${path}${target}`
49
+ );
50
+ };
51
+
52
+ /**
53
+ * `extraDirs` adds more directories to the fake filesystem (a pre-existing tree outside every
54
+ * prefix, say); it never changes the runner's OWN prefixes, which stay `PREFIXES` throughout.
55
+ * `acl` is the set of directory paths a test wants to carry the `+` (POSIX ACL) flag.
56
+ */
57
+ export const fakeSudoHost = (
58
+ extraDirs: Record<string, number> = {},
59
+ acl: ReadonlySet<string> = new Set(),
60
+ ) => {
61
+ const fake = fakeLinuxHost({
62
+ dirs: { '/etc/systemd/system': 0, '/usr/local/bin': 0, ...extraDirs },
63
+ euid: OPERATOR,
64
+ users: { app: { gid: 60, home: '/opt/app', uid: 900 } },
65
+ });
66
+ let mktempCount = 0;
67
+ const logs: string[] = [];
68
+ const sudoCalls: string[][] = [];
69
+ const state: RootState & { sudo: 'ok' | keyof typeof SUDO_SAYS } = { sudo: 'ok' };
70
+
71
+ const handleLs = async (paths: readonly string[]): Promise<ExecResult> => {
72
+ const lines: string[] = [];
73
+ for (const path of paths) {
74
+ const stat = await fake.runner.stat(path);
75
+ if (stat === undefined)
76
+ return fail(2, `ls: cannot access '${path}': No such file or directory`);
77
+ lines.push(lsLine(path, stat, acl.has(path)));
78
+ }
79
+ return ok(`${lines.join('\n')}\n`);
80
+ };
81
+
82
+ const handleMktemp = (): ExecResult => {
83
+ mktempCount += 1;
84
+ const dir = `/tmp/hf-sudo-${String(mktempCount)}`;
85
+ fake.dirs.set(dir, OPERATOR);
86
+ fake.modes.set(dir, { gid: 0, mode: 0o700, uid: OPERATOR });
87
+ return ok(`${dir}\n`);
88
+ };
89
+
90
+ /** `rm -rf -- <dir>`, as the operator — `sudo-stage.ts`'s own cleanup of its staging directory. */
91
+ const handleRm = (argv: readonly string[]): ExecResult => {
92
+ const target = argv.at(-1);
93
+ if (target === undefined) return fail(1, 'rm: missing operand');
94
+ const under = (path: string) => path === target || path.startsWith(`${target}/`);
95
+ for (const path of [...fake.files.keys()].filter(under)) fake.files.delete(path);
96
+ for (const path of [...fake.dirs.keys()].filter(under)) {
97
+ fake.dirs.delete(path);
98
+ fake.modes.delete(path);
99
+ }
100
+ return ok();
101
+ };
102
+
103
+ const base: HostRunner = {
104
+ ...fake.runner,
105
+ exec: async (argv) => {
106
+ // ★ Read-only, operator-level calls now go by absolute path too (cosmetic hardening from
107
+ // the 2026-09-23 adversarial review — see the sources' own headers); fakeLinuxHost's own
108
+ // dispatcher only recognises the bare names it was built against, so both spellings are
109
+ // handled here and `systemctl`'s absolute form is translated back to bare before it is
110
+ // handed off, rather than teaching the shared fixture a second spelling.
111
+ if (argv[0] === 'ls' || argv[0] === '/usr/bin/ls') {
112
+ if (argv[1] === '-ldn') return handleLs(argv.slice(3));
113
+ }
114
+ if ((argv[0] === 'mktemp' || argv[0] === '/usr/bin/mktemp') && argv[1] === '-d') {
115
+ return handleMktemp();
116
+ }
117
+ if (argv[0] === 'rm' || argv[0] === '/usr/bin/rm') return handleRm(argv);
118
+ if (argv[0] === '/usr/bin/systemctl')
119
+ return fake.runner.exec(['systemctl', ...argv.slice(1)]);
120
+ if (argv[0] !== SUDO) return fake.runner.exec(argv);
121
+ sudoCalls.push([...argv]);
122
+ if (logs[logs.length - 1] !== `homeflare/linux sudo -n ${JSON.stringify(argv.slice(3))}`) {
123
+ throw new Error(`fake sudo: ${JSON.stringify(argv)} was not logged before it ran`);
124
+ }
125
+ if (state.sudo !== 'ok') return fail(1, SUDO_SAYS[state.sudo]);
126
+ return asRoot(fake, argv.slice(3), state);
127
+ },
128
+ };
129
+
130
+ const runner = makeSshSudoRunner(PREFIXES, {
131
+ base,
132
+ log: (line) => logs.push(line),
133
+ // ★ The real stager, unmodified: it only ever calls `base.exec`/`base.writeFileAtomic`, both
134
+ // of which this fake's `mktemp`/`ls`/operator-level paths already answer.
135
+ stage: stageRemote(base),
136
+ });
137
+ /** The privileged commands run, without the `sudo -n --` in front. */
138
+ const privileged = () => sudoCalls.map((call) => call.slice(3));
139
+ // ★ `base` (unprivileged, but with `ls`/`mktemp` support) is exposed for sudo-guard.test.ts,
140
+ // which exercises assertGuardedChain directly, below the runner it is a building block of.
141
+ return { base, fake, logs, privileged, runner, state };
142
+ };
@@ -19,6 +19,8 @@ export type { RemoteFileAttributes, RemoteFileProps } from './remote-file-form.t
19
19
  export { RemoteFile, RemoteFileProvider } from './remote-file.ts';
20
20
  export type { SshRunnerOptions } from './ssh-runner.ts';
21
21
  export { sshRunner } from './ssh-runner.ts';
22
+ export type { SshSudoRunnerOptions } from './sudo-runner.ts';
23
+ export { sshSudoRunner } from './sudo-runner.ts';
22
24
  export type {
23
25
  ExecResult,
24
26
  FileStat,
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `chown`'s two privileged shapes, split out of sudo-allowlist.ts to keep it under the house's
3
+ * 250-line cap: the ROOT-ONLY directory form (sudo-allowlist-dir.ts owns that check) and the
4
+ * FILE-ownership form used only on the derived temp file `sudo-write.ts` `install`s into, for a
5
+ * `RemoteFile` whose declared owner or group is not root.
6
+ *
7
+ * ⛔ `install`'S OWN `-o`/`-g` ARE NEVER USED, ON EITHER PLATFORM'S ALLOWLIST NOW. 🔴 MEASURED
8
+ * (adversarial review, round 2, 2026-09-23, against coreutils 9.7 `src/install.c` `get_ids()`):
9
+ * unlike `chown`, `install` does NOT go through gnulib's `+`-aware `userspec.c` at all — its own
10
+ * `get_ids()` calls `getpwnam(owner_name)` / `getgrnam(group_name)` FIRST for ANY string,
11
+ * falling back to `xstrtoumax` only when no such account exists, with no `+`-prefix escape of
12
+ * any kind. So `install -o 0` on a host with a user or group literally named `"0"` installs
13
+ * owned by THAT account, not uid 0 — and there is no argv shape that closes this for `install`
14
+ * itself. The fix is to never let `install` set ownership: it always runs with no `-o`/`-g` (so
15
+ * the fresh temp file lands owned by whoever `sudo` runs it as — root:root, with no name lookup
16
+ * at all, since omitting the flags skips `get_ids` entirely), and this file's `chown` shape sets
17
+ * the FINAL ownership afterward, on the same temp file, before `mv`. `chown` DOES support `+`
18
+ * (gnulib `userspec.c`: `*u == '+' ? NULL : getpwnam(u)`, "if it starts with '+', skip the
19
+ * look-up") — repeated on each side of a `user:group` spec independently — so this is where the
20
+ * fix actually lands: `+<uid>`, `:+<gid>`, or `+<uid>:+<gid>`, never a bare digit string.
21
+ */
22
+ import { CHOWN, dirProgramProblem } from './sudo-allowlist-dir.ts';
23
+
24
+ /** Bare `directory-lifecycle.ts` argv (`0`, `:0`, `0:0`, …) → the `+`-forced form sudo may run. */
25
+ export const forceNumericOwner = (spec: string): string => {
26
+ const [uidPart, gidPart] = spec.includes(':') ? spec.split(':') : [spec, undefined];
27
+ const uid = uidPart === undefined || uidPart === '' ? '' : `+${uidPart}`;
28
+ const gid = gidPart === undefined ? '' : `:+${gidPart}`;
29
+ return `${uid}${gid}`;
30
+ };
31
+
32
+ /** `+<uid>`, `:+<gid>`, or `+<uid>:+<gid>` — numeric, `+`-forced, either or both sides. */
33
+ const SAFE_OWNER = /^(\+\d+(:\+\d+)?|:\+\d+)$/;
34
+
35
+ /**
36
+ * `chown <safe spec> -- <temp>`, bound to the exact temp `install` just wrote — the file-ownership
37
+ * half of `writeUnderPrefix`'s sequence. Any owner/group is allowed (a `RemoteFile` may declare
38
+ * one), as long as it is numeric and forced; the temp binding is what stops it reaching anywhere
39
+ * else, the same way `mv`'s own binding does.
40
+ */
41
+ const fileChownProblem = (args: readonly string[], temp: string): string | undefined => {
42
+ const [owner, end, path, ...extra] = args;
43
+ if (
44
+ owner !== undefined &&
45
+ SAFE_OWNER.test(owner) &&
46
+ end === '--' &&
47
+ extra.length === 0 &&
48
+ path === temp
49
+ ) {
50
+ return undefined;
51
+ }
52
+ return (
53
+ 'chown on the staged temp takes exactly `+<uid>`, `:+<gid>` or `+<uid>:+<gid>` -- ' +
54
+ '<the exact temp install just wrote>, numeric and +-forced'
55
+ );
56
+ };
57
+
58
+ /**
59
+ * `chown`'s dispatch: a call bound to `context.temp` (its last argument equals it) is the
60
+ * file-ownership form above; everything else is the directory form, root-only
61
+ * (`sudo-allowlist-dir.ts`'s own `ROOT_OWNER`, `+`-forced the same way).
62
+ */
63
+ export const chownProblem = (
64
+ args: readonly string[],
65
+ context: { readonly prefixes: readonly string[]; readonly temp?: string },
66
+ ): string | undefined => {
67
+ const path = args[args.length - 1];
68
+ if (context.temp !== undefined && path === context.temp)
69
+ return fileChownProblem(args, context.temp);
70
+ return dirProgramProblem(CHOWN, args, context.prefixes);
71
+ };