@homeflare/alchemy 0.25.1 → 0.27.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 (118) hide show
  1. package/dist/github/declare-repo-baseline.d.ts +11 -0
  2. package/dist/github/declare-repo-baseline.d.ts.map +1 -0
  3. package/dist/github/index.d.ts +20 -11
  4. package/dist/github/index.d.ts.map +1 -1
  5. package/dist/github/index.js +715 -4
  6. package/dist/github/index.js.map +14 -4
  7. package/dist/github/repo-baseline-data.d.ts +75 -0
  8. package/dist/github/repo-baseline-data.d.ts.map +1 -0
  9. package/dist/github/repo-policy.d.ts +4 -4
  10. package/dist/github/repo-policy.d.ts.map +1 -1
  11. package/dist/github/repository-ruleset-constraints.d.ts +105 -0
  12. package/dist/github/repository-ruleset-constraints.d.ts.map +1 -0
  13. package/dist/github/repository-ruleset-errors.d.ts +97 -0
  14. package/dist/github/repository-ruleset-errors.d.ts.map +1 -0
  15. package/dist/github/repository-ruleset-fake-octokit.d.ts +24 -0
  16. package/dist/github/repository-ruleset-fake-octokit.d.ts.map +1 -0
  17. package/dist/github/repository-ruleset-fixtures.d.ts +20 -0
  18. package/dist/github/repository-ruleset-fixtures.d.ts.map +1 -0
  19. package/dist/github/repository-ruleset-form.d.ts +30 -0
  20. package/dist/github/repository-ruleset-form.d.ts.map +1 -0
  21. package/dist/github/repository-ruleset-guards.d.ts +70 -0
  22. package/dist/github/repository-ruleset-guards.d.ts.map +1 -0
  23. package/dist/github/repository-ruleset-octokit.d.ts +5 -0
  24. package/dist/github/repository-ruleset-octokit.d.ts.map +1 -0
  25. package/dist/github/repository-ruleset-probe.d.ts +87 -0
  26. package/dist/github/repository-ruleset-probe.d.ts.map +1 -0
  27. package/dist/github/repository-ruleset-reconcile.d.ts +24 -0
  28. package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -0
  29. package/dist/github/repository-ruleset-test-helpers.d.ts +32 -0
  30. package/dist/github/repository-ruleset-test-helpers.d.ts.map +1 -0
  31. package/dist/github/repository-ruleset.d.ts +89 -0
  32. package/dist/github/repository-ruleset.d.ts.map +1 -0
  33. package/dist/launchd/index.js +51 -47
  34. package/dist/launchd/index.js.map +5 -4
  35. package/dist/launchd/sudo-guard-mode.d.ts +62 -0
  36. package/dist/launchd/sudo-guard-mode.d.ts.map +1 -0
  37. package/dist/launchd/sudo-guard.d.ts +1 -38
  38. package/dist/launchd/sudo-guard.d.ts.map +1 -1
  39. package/dist/linux/fake-sudo-root.d.ts +26 -0
  40. package/dist/linux/fake-sudo-root.d.ts.map +1 -0
  41. package/dist/linux/fake-sudo.d.ts +56 -0
  42. package/dist/linux/fake-sudo.d.ts.map +1 -0
  43. package/dist/linux/index.d.ts +2 -0
  44. package/dist/linux/index.d.ts.map +1 -1
  45. package/dist/linux/index.js +1134 -2
  46. package/dist/linux/index.js.map +22 -5
  47. package/dist/linux/sudo-allowlist-chown.d.ts +12 -0
  48. package/dist/linux/sudo-allowlist-chown.d.ts.map +1 -0
  49. package/dist/linux/sudo-allowlist-dir.d.ts +6 -0
  50. package/dist/linux/sudo-allowlist-dir.d.ts.map +1 -0
  51. package/dist/linux/sudo-allowlist.d.ts +40 -0
  52. package/dist/linux/sudo-allowlist.d.ts.map +1 -0
  53. package/dist/linux/sudo-exec.d.ts +31 -0
  54. package/dist/linux/sudo-exec.d.ts.map +1 -0
  55. package/dist/linux/sudo-guard.d.ts +36 -0
  56. package/dist/linux/sudo-guard.d.ts.map +1 -0
  57. package/dist/linux/sudo-runner.d.ts +37 -0
  58. package/dist/linux/sudo-runner.d.ts.map +1 -0
  59. package/dist/linux/sudo-stage.d.ts +22 -0
  60. package/dist/linux/sudo-stage.d.ts.map +1 -0
  61. package/dist/linux/sudo-write.d.ts +25 -0
  62. package/dist/linux/sudo-write.d.ts.map +1 -0
  63. package/dist/linux/unit-form.d.ts +2 -0
  64. package/dist/linux/unit-form.d.ts.map +1 -1
  65. package/dist/litellm/fake-litellm.d.ts.map +1 -1
  66. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -1
  67. package/docs/launchd-sudo.md +12 -3
  68. package/docs/linux-host.md +3 -3
  69. package/docs/linux-sudo.md +187 -0
  70. package/docs/repo-policy-ruleset-hazards.md +8 -0
  71. package/docs/repo-policy.md +27 -12
  72. package/docs/repository-ruleset.md +114 -0
  73. package/docs/upstream-conformance.md +12 -1
  74. package/docs/upstream-pr-ruleset-fixes.md +77 -0
  75. package/package.json +1 -1
  76. package/src/github/declare-repo-baseline.ts +42 -0
  77. package/src/github/index.ts +43 -11
  78. package/src/github/repo-baseline-data.test.ts +76 -0
  79. package/src/github/repo-baseline-data.ts +86 -0
  80. package/src/github/repo-policy.ts +17 -17
  81. package/src/github/repository-ruleset-constraints.ts +136 -0
  82. package/src/github/repository-ruleset-coverage.test.ts +82 -0
  83. package/src/github/repository-ruleset-errors.ts +136 -0
  84. package/src/github/repository-ruleset-fake-octokit.ts +131 -0
  85. package/src/github/repository-ruleset-fixtures.ts +183 -0
  86. package/src/github/repository-ruleset-form.ts +212 -0
  87. package/src/github/repository-ruleset-guards.ts +194 -0
  88. package/src/github/repository-ruleset-lifecycle.test.ts +172 -0
  89. package/src/github/repository-ruleset-octokit.ts +140 -0
  90. package/src/github/repository-ruleset-probe.ts +116 -0
  91. package/src/github/repository-ruleset-reconcile.ts +176 -0
  92. package/src/github/repository-ruleset-test-helpers.ts +102 -0
  93. package/src/github/repository-ruleset-wire.test.ts +66 -0
  94. package/src/github/repository-ruleset-write-refusals.test.ts +199 -0
  95. package/src/github/repository-ruleset-write.test.ts +83 -0
  96. package/src/github/repository-ruleset.ts +219 -0
  97. package/src/launchd/sudo-guard-mode.ts +111 -0
  98. package/src/launchd/sudo-guard.ts +3 -79
  99. package/src/launchd/sudo-modes.test.ts +38 -1
  100. package/src/linux/fake-sudo-root.ts +157 -0
  101. package/src/linux/fake-sudo.ts +142 -0
  102. package/src/linux/index.ts +2 -0
  103. package/src/linux/sudo-allowlist-chown.ts +71 -0
  104. package/src/linux/sudo-allowlist-dir.ts +103 -0
  105. package/src/linux/sudo-allowlist.test.ts +252 -0
  106. package/src/linux/sudo-allowlist.ts +223 -0
  107. package/src/linux/sudo-exec.ts +118 -0
  108. package/src/linux/sudo-guard.test.ts +136 -0
  109. package/src/linux/sudo-guard.ts +142 -0
  110. package/src/linux/sudo-lifecycle.test.ts +151 -0
  111. package/src/linux/sudo-runner.test.ts +206 -0
  112. package/src/linux/sudo-runner.ts +164 -0
  113. package/src/linux/sudo-stage.ts +50 -0
  114. package/src/linux/sudo-write.ts +167 -0
  115. package/src/linux/unit-form.ts +2 -1
  116. package/src/litellm/fake-litellm.ts +2 -0
  117. package/src/proxmox/client.test.ts +17 -3
  118. package/src/proxmox/fake-pve-lxc.ts +10 -1
@@ -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
+ };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The `mkdir` / `chmod` / `chown` / `rmdir` half of sudo-allowlist.ts, split out to keep that file
3
+ * under the house's 250-line cap. These four are `Host.Directory`'s whole write surface
4
+ * (directory-lifecycle.ts): this file fixes their argv to the EXACT shapes that file emits.
5
+ *
6
+ * ⛔ `chown` TAKES ONLY NUMERIC IDS, NEVER A NAME. `directory-lifecycle.ts`'s `ownerArg()` already
7
+ * resolves a name to a uid/gid before building argv, so a name here would mean some other code
8
+ * path built this argv — refused rather than trusted.
9
+ * 🔴 MEASURED (adversarial review, 2026-09-23, against this file's first shipped version): NEITHER
10
+ * the mode nor the owner was checked for anything but shape. `mkdir -m 0777 -- <path>` and
11
+ * `chown 1001:1001 -- <path>` both passed the allowlist untouched. A stack could declare
12
+ * `HostDirectory({ path: '/etc/systemd/system/x.service.d', mode: 0o777 })`, drop a
13
+ * `ExecStart=` override into it, and the runner's own next `daemon-reload` + `restart` would run
14
+ * it as root — a local privilege escalation reachable through this runner's own allowlist, not a
15
+ * bug in the guard (which never runs against a path this permissive by design).
16
+ * ⛔ SO: NO SPECIAL BITS, NO GROUP/OTHER WRITE, EVER — mirroring the file half's `modeProblem`
17
+ * (`../launchd/sudo-guard.ts`) but WITHOUT its "handed to another owner" exemption. A directory's
18
+ * OWNER always has write access through the owner bits alone, so no mode restriction can make a
19
+ * non-root-owned directory here safe — which is why `chown` below is refused to anything but
20
+ * root outright, not merely mode-restricted.
21
+ * 🔴 MEASURED (adversarial review, round 2, 2026-09-23, cited against gnulib's `lib/userspec.c`,
22
+ * used by GNU `chown`): a purely-numeric owner string is looked up as a NAME first —
23
+ * `*u == '+' ? NULL : getpwnam(u)` — and only parsed as a raw number when no such account
24
+ * exists. If a host ever had a user or group literally NAMED `"0"`, `chown 0 -- path` would
25
+ * silently chown to THAT account, not uid 0 — bypassing this file's own root-only restriction
26
+ * without tripping it. `+` skips the name lookup outright (`if it starts with '+', skip the
27
+ * look-up`, `userspec.c`), so the allowlist now requires it: `sudo-allowlist.ts`'s
28
+ * `canonicalize()` rewrites the bare `0`/`:0`/`0:0` argv `directory-lifecycle.ts` actually sends
29
+ * into the forced-numeric form below before this check ever runs, so the unprivileged caller
30
+ * never has to know this syntax exists.
31
+ */
32
+ import { prefixOf } from '../launchd/sudo-allowlist.ts';
33
+
34
+ export const MKDIR = '/usr/bin/mkdir';
35
+ export const CHMOD = '/usr/bin/chmod';
36
+ export const CHOWN = '/usr/bin/chown';
37
+ export const RMDIR = '/usr/bin/rmdir';
38
+
39
+ /**
40
+ * `mkdir -m` / `chmod`'s mode, exactly as `directory-lifecycle.ts`'s own `octal()` formats it:
41
+ * `mode.toString(8).padStart(3, '0')` — 3 digits, or 4 when a special bit makes it longer.
42
+ * Parsed to a number so the setuid/setgid/group/other-write bits can be checked directly, the same
43
+ * mask `modeProblem` uses (`0o6000 | 0o022`).
44
+ */
45
+ const parseDirMode = (text: string): number | undefined =>
46
+ /^([0-7]{3}|[1-7][0-7]{3})$/.test(text) ? Number.parseInt(text, 8) : undefined;
47
+
48
+ const DANGEROUS_MODE_BITS = 0o6022;
49
+
50
+ /** `undefined` when `mode` is a safe directory mode: no setuid/setgid, no group/other write. */
51
+ const dirModeProblem = (mode: number | undefined): string | undefined => {
52
+ if (mode === undefined) return 'mode';
53
+ return (mode & DANGEROUS_MODE_BITS) !== 0
54
+ ? 'a mode with setuid/setgid or group/other write (this runner installs only 0–0o777 minus 0o022, no special bits)'
55
+ : undefined;
56
+ };
57
+
58
+ /**
59
+ * `chown`'s owner argument — root only, and `+`-forced so a same-named account can never be
60
+ * resolved instead of uid/gid `0`. `canonicalize()` builds these from the bare `0`/`:0`/`0:0`
61
+ * `directory-lifecycle.ts`'s `ownerArg()` sends; this checks what actually reaches `sudo`.
62
+ */
63
+ const ROOT_OWNER = /^(\+0|:\+0|\+0:\+0)$/;
64
+
65
+ export const dirProgramProblem = (
66
+ program: string,
67
+ args: readonly string[],
68
+ prefixes: readonly string[],
69
+ ): string | undefined => {
70
+ const under = (path: string | undefined) =>
71
+ path !== undefined && prefixOf(path, prefixes) !== undefined;
72
+ if (program === MKDIR) {
73
+ const [m, modeText, end, path, ...extra] = args;
74
+ const mode = modeText === undefined ? undefined : parseDirMode(modeText);
75
+ if (m !== '-m' || mode === undefined || end !== '--' || extra.length !== 0 || !under(path)) {
76
+ return 'mkdir takes exactly `-m <octal> -- <path under a declared prefix>`';
77
+ }
78
+ return dirModeProblem(mode);
79
+ }
80
+ if (program === CHMOD) {
81
+ const [modeText, end, path, ...extra] = args;
82
+ const mode = modeText === undefined ? undefined : parseDirMode(modeText);
83
+ if (mode === undefined || end !== '--' || extra.length !== 0 || !under(path)) {
84
+ return 'chmod takes exactly `<octal> -- <path under a declared prefix>`';
85
+ }
86
+ return dirModeProblem(mode);
87
+ }
88
+ if (program === CHOWN) {
89
+ const [owner, end, path, ...extra] = args;
90
+ return owner !== undefined &&
91
+ ROOT_OWNER.test(owner) &&
92
+ end === '--' &&
93
+ extra.length === 0 &&
94
+ under(path)
95
+ ? undefined
96
+ : 'chown takes exactly `+0`, `:+0` or `+0:+0` -- <path under a declared prefix>` — root ' +
97
+ 'only, forced numeric; a directory this runner elevates never hands ownership to anyone else';
98
+ }
99
+ const [end, path, ...extra] = args;
100
+ return end === '--' && extra.length === 0 && under(path)
101
+ ? undefined
102
+ : 'rmdir takes exactly `-- <path under a declared prefix>`';
103
+ };