@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,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
+ };
@@ -0,0 +1,252 @@
1
+ /**
2
+ * The allowlist, as pure argv checks — no ssh, no fake host. Each refusal here is a shape that
3
+ * would do real damage as root; each acceptance is a shape sshSudoRunner actually produces.
4
+ */
5
+ import { describe, expect, test } from 'bun:test';
6
+ import {
7
+ type AllowContext,
8
+ CHMOD,
9
+ CHOWN,
10
+ INSTALL,
11
+ MKDIR,
12
+ MV,
13
+ RM,
14
+ RMDIR,
15
+ SYSTEMCTL_ABS,
16
+ canonicalize,
17
+ checkPrefixes,
18
+ octalMode,
19
+ prefixOf,
20
+ privilegedProblem,
21
+ routeExec,
22
+ } from './sudo-allowlist.ts';
23
+
24
+ const PREFIXES = ['/etc/systemd/system', '/usr/local/bin'];
25
+ const STAGED = '/tmp/hf-sudo-1/staged';
26
+ const TEMP = '/etc/systemd/system/.thing.service.hf-abcdef012345.tmp';
27
+ const context: AllowContext = { prefixes: PREFIXES, staged: STAGED, temp: TEMP };
28
+ const allowed = (argv: string[]) => privilegedProblem(argv, context);
29
+
30
+ describe('checkPrefixes / prefixOf', () => {
31
+ test('reused from the Mac allowlist unchanged', () => {
32
+ expect(() => checkPrefixes([])).toThrow('no prefixes');
33
+ expect(checkPrefixes(PREFIXES)).toEqual(PREFIXES);
34
+ expect(prefixOf('/etc/systemd/system/x.service', PREFIXES)).toBe('/etc/systemd/system');
35
+ expect(prefixOf('/etc/systemd/system-other/x', PREFIXES)).toBeUndefined();
36
+ expect(octalMode(0o644)).toBe('0644');
37
+ });
38
+ });
39
+
40
+ describe('install shapes', () => {
41
+ test.each([
42
+ [[INSTALL, '-m', '0644', '-T', '--', STAGED, TEMP]],
43
+ [[INSTALL, '-m', '0755', '-T', '--', STAGED, TEMP]],
44
+ ])('allows %j', (argv) => {
45
+ expect(allowed(argv)).toBeUndefined();
46
+ });
47
+
48
+ test.each([
49
+ ['a source it did not stage', [INSTALL, '-m', '0644', '-T', '--', '/etc/master.passwd', TEMP]],
50
+ [
51
+ 'a target that is not the derived temp',
52
+ [INSTALL, '-m', '0644', '-T', '--', STAGED, '/etc/systemd/system/x.service'],
53
+ ],
54
+ ['a temp outside every prefix', [INSTALL, '-m', '0644', '-T', '--', STAGED, '/tmp/x.tmp']],
55
+ ['no -T', [INSTALL, '-m', '0644', '--', STAGED, TEMP]],
56
+ ['a three-digit mode', [INSTALL, '-m', '644', '-T', '--', STAGED, TEMP]],
57
+ ['-S (the Mac flag)', [INSTALL, '-S', '-m', '0644', '-T', '--', STAGED, TEMP]],
58
+ ['two destinations', [INSTALL, '-m', '0644', '-T', '--', STAGED, TEMP, TEMP]],
59
+ // 🔴 Adversarial review, round 2: install has no `+`-forcing mechanism at all (coreutils'
60
+ // `get_ids()` always tries getpwnam/getgrnam first), so -o/-g are refused outright now —
61
+ // ownership is set afterward by a separate, `+`-forced chown on the same temp (below).
62
+ ['-o, now refused outright', [INSTALL, '-m', '0644', '-o', '0', '-T', '--', STAGED, TEMP]],
63
+ ['-g, now refused outright', [INSTALL, '-m', '0644', '-g', '0', '-T', '--', STAGED, TEMP]],
64
+ ])('refuses %s', (_name, argv) => {
65
+ expect(allowed(argv)).toBeString();
66
+ });
67
+ });
68
+
69
+ describe('the file-ownership chown, bound to the derived temp', () => {
70
+ test.each([
71
+ [[CHOWN, '+900', '--', TEMP]],
72
+ [[CHOWN, ':+60', '--', TEMP]],
73
+ [[CHOWN, '+900:+60', '--', TEMP]],
74
+ ])('allows %j', (argv) => {
75
+ expect(allowed(argv)).toBeUndefined();
76
+ });
77
+
78
+ test.each([
79
+ ['a bare digit, not +-forced', [CHOWN, '900', '--', TEMP]],
80
+ ['a name', [CHOWN, '+root', '--', TEMP]],
81
+ ['a path that is not the derived temp', [CHOWN, '+900', '--', '/usr/local/bin/other']],
82
+ ['only the group side forced', [CHOWN, '900:+60', '--', TEMP]],
83
+ ['only the owner side forced', [CHOWN, '+900:60', '--', TEMP]],
84
+ ])('refuses %s', (_name, argv) => {
85
+ expect(allowed(argv)).toBeString();
86
+ });
87
+ });
88
+
89
+ describe('mv and rm shapes', () => {
90
+ test.each([
91
+ [[MV, '-f', '-T', '--', TEMP, '/etc/systemd/system/thing.service']],
92
+ [[RM, '-f', '--', TEMP]],
93
+ ])('allows %j', (argv) => {
94
+ expect(allowed(argv)).toBeUndefined();
95
+ });
96
+
97
+ test.each([
98
+ [
99
+ 'mv source not the derived temp',
100
+ [MV, '-f', '-T', '--', '/etc/systemd/system/other.tmp', '/etc/systemd/system/thing.service'],
101
+ ],
102
+ ['mv to another directory', [MV, '-f', '-T', '--', TEMP, '/usr/local/bin/thing']],
103
+ ['mv without -T', [MV, '-f', '--', TEMP, '/etc/systemd/system/thing.service']],
104
+ ['rm -r', [RM, '-rf', '--', TEMP]],
105
+ ['rm without --', [RM, '-f', TEMP]],
106
+ ['rm outside every prefix', [RM, '-f', '--', '/tmp/x']],
107
+ ['sudo in argv', ['/usr/bin/sudo', '-n', RM, '-f', '--', TEMP]],
108
+ ])('refuses %s', (_name, argv) => {
109
+ expect(allowed(argv)).toBeString();
110
+ });
111
+ });
112
+
113
+ describe('directory shapes', () => {
114
+ test.each([
115
+ [[MKDIR, '-m', '750', '--', '/etc/systemd/system/sub']],
116
+ [[MKDIR, '-m', '700', '--', '/etc/systemd/system/sub']],
117
+ [[CHMOD, '750', '--', '/usr/local/bin/sub']],
118
+ // ★ +-forced: this is what canonicalize() actually sends to sudo for a directory chown; the
119
+ // bare `0`/`:0`/`0:0` directory-lifecycle.ts itself sends is covered by the canonicalize
120
+ // test below, and is refused HERE on purpose (privilegedProblem checks the final argv).
121
+ [[CHOWN, '+0', '--', '/usr/local/bin/sub']],
122
+ [[CHOWN, ':+0', '--', '/usr/local/bin/sub']],
123
+ [[CHOWN, '+0:+0', '--', '/usr/local/bin/sub']],
124
+ [[RMDIR, '--', '/etc/systemd/system/sub']],
125
+ ])('allows %j', (argv) => {
126
+ expect(allowed(argv)).toBeUndefined();
127
+ });
128
+
129
+ test.each([
130
+ ['a relative path', [MKDIR, '-m', '750', '--', 'etc/x']],
131
+ ['a prefix-sibling', [MKDIR, '-m', '750', '--', '/usr/local/bin-x/sub']],
132
+ ['a path that walks out', [MKDIR, '-m', '750', '--', '/etc/systemd/system/../x']],
133
+ ['chown by name', [CHOWN, 'root', '--', '/usr/local/bin/sub']],
134
+ [
135
+ 'chown, bare digit not +-forced (round 2 of the review)',
136
+ [CHOWN, '0', '--', '/usr/local/bin/sub'],
137
+ ],
138
+ ['chmod without --', [CHMOD, '750', '/usr/local/bin/sub']],
139
+ ['rmdir of two paths', [RMDIR, '--', '/usr/local/bin/sub', '/etc/systemd/system/sub']],
140
+ // 🔴 The three shapes the adversarial review found reachable, 2026-09-23 — see
141
+ // sudo-allowlist-dir.ts's header. Each must now be refused, not merely shaped correctly.
142
+ ['mkdir with setuid (4750)', [MKDIR, '-m', '4750', '--', '/etc/systemd/system/sub']],
143
+ ['mkdir world-writable (0777)', [MKDIR, '-m', '777', '--', '/etc/systemd/system/sub']],
144
+ [
145
+ 'mkdir setgid + group-writable (2775)',
146
+ [MKDIR, '-m', '2775', '--', '/etc/systemd/system/sub'],
147
+ ],
148
+ ['chmod group-writable (0775)', [CHMOD, '775', '--', '/usr/local/bin/sub']],
149
+ ['chown to a non-root uid', [CHOWN, '900', '--', '/usr/local/bin/sub']],
150
+ ['chown to a non-root gid only', [CHOWN, ':60', '--', '/usr/local/bin/sub']],
151
+ ['chown to a non-root uid and gid', [CHOWN, '900:60', '--', '/usr/local/bin/sub']],
152
+ ['chown to a non-root uid, even +-forced', [CHOWN, '+900', '--', '/usr/local/bin/sub']],
153
+ ])('refuses %s', (_name, argv) => {
154
+ expect(allowed(argv)).toBeString();
155
+ });
156
+ });
157
+
158
+ describe('systemctl shapes', () => {
159
+ test.each([
160
+ [[SYSTEMCTL_ABS, 'daemon-reload']],
161
+ [[SYSTEMCTL_ABS, 'enable', '--', 'thing.service']],
162
+ [[SYSTEMCTL_ABS, 'disable', '--', 'thing.service']],
163
+ [[SYSTEMCTL_ABS, 'start', '--', 'thing.timer']],
164
+ [[SYSTEMCTL_ABS, 'restart', '--', 'thing.service']],
165
+ ])('allows %j', (argv) => {
166
+ expect(allowed(argv)).toBeUndefined();
167
+ });
168
+
169
+ test.each([
170
+ ['--user', [SYSTEMCTL_ABS, '--user', 'enable', '--', 'thing.service']],
171
+ ['stop with two units', [SYSTEMCTL_ABS, 'stop', '--', 'a.service', 'b.service']],
172
+ ['mask', [SYSTEMCTL_ABS, 'mask', '--', 'thing.service']],
173
+ ['daemon-reload with an operand', [SYSTEMCTL_ABS, 'daemon-reload', 'thing.service']],
174
+ ['a unit that is not a valid name', [SYSTEMCTL_ABS, 'enable', '--', 'not a unit']],
175
+ ])('refuses %s', (_name, argv) => {
176
+ expect(allowed(argv)).toBeString();
177
+ });
178
+ });
179
+
180
+ describe('canonicalize', () => {
181
+ test('rewrites a bare directory chown to the +-forced form the allowlist now requires', () => {
182
+ expect(canonicalize(['chown', '0', '--', '/usr/local/bin/sub'])).toEqual([
183
+ CHOWN,
184
+ '+0',
185
+ '--',
186
+ '/usr/local/bin/sub',
187
+ ]);
188
+ expect(canonicalize(['chown', ':0', '--', '/usr/local/bin/sub'])).toEqual([
189
+ CHOWN,
190
+ ':+0',
191
+ '--',
192
+ '/usr/local/bin/sub',
193
+ ]);
194
+ expect(canonicalize(['chown', '900:60', '--', '/usr/local/bin/sub'])).toEqual([
195
+ CHOWN,
196
+ '+900:+60',
197
+ '--',
198
+ '/usr/local/bin/sub',
199
+ ]);
200
+ });
201
+
202
+ test('leaves every other program alone', () => {
203
+ expect(canonicalize(['mkdir', '-m', '750', '--', '/usr/local/bin/sub'])).toEqual([
204
+ MKDIR,
205
+ '-m',
206
+ '750',
207
+ '--',
208
+ '/usr/local/bin/sub',
209
+ ]);
210
+ });
211
+ });
212
+
213
+ describe('routeExec', () => {
214
+ const route = (argv: string[]) => routeExec(argv, PREFIXES);
215
+
216
+ test('systemctl write verbs always attempt root, even malformed', () => {
217
+ expect(route([SYSTEMCTL_ABS, 'daemon-reload'])).toEqual({ as: 'root' });
218
+ expect(route(['systemctl', 'enable', '--', 'thing.service'])).toEqual({ as: 'root' });
219
+ expect(route(['systemctl', 'stop', '--', 'a', 'b'])).toEqual({ as: 'root' });
220
+ });
221
+
222
+ test('systemctl reads stay unelevated', () => {
223
+ expect(route(['systemctl', 'show', '-p', 'LoadState', '--', 'thing.service'])).toEqual({
224
+ as: 'operator',
225
+ });
226
+ });
227
+
228
+ test('directory programs go to root only under a declared prefix', () => {
229
+ expect(route(['mkdir', '-m', '750', '--', '/usr/local/bin/sub'])).toEqual({ as: 'root' });
230
+ expect(route(['mkdir', '-m', '750', '--', '/opt/elsewhere'])).toEqual({ as: 'operator' });
231
+ });
232
+
233
+ test('the absolute program spelling routes the same as the bare one', () => {
234
+ expect(route([MKDIR, '-m', '750', '--', '/usr/local/bin/sub'])).toEqual({ as: 'root' });
235
+ expect(route([CHOWN, '0', '--', '/usr/local/bin/sub'])).toEqual({ as: 'root' });
236
+ });
237
+
238
+ test('a sudo argv is refused outright, bare or absolute', () => {
239
+ expect(route(['/usr/bin/sudo', '-n', RM, '-f', '--', '/etc/x'])).toHaveProperty('refuse');
240
+ expect(route(['sudo', 'true'])).toHaveProperty('refuse');
241
+ });
242
+
243
+ test('systemctl with a banned flag is refused, not routed to the operator', () => {
244
+ expect(route(['systemctl', 'enable', '--global', '--', 'thing.service'])).toHaveProperty(
245
+ 'refuse',
246
+ );
247
+ });
248
+
249
+ test('everything else runs as the operator', () => {
250
+ expect(route(['getent', 'passwd', '900'])).toEqual({ as: 'operator' });
251
+ });
252
+ });
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Everything sshSudoRunner may run as root, as exact argv shapes, plus the rule for which calls
3
+ * need root at all. Pure: no I/O, so every shape is tested without ssh (sudo-allowlist.test.ts).
4
+ * The Linux twin of `../launchd/sudo-allowlist.ts` — reused directly where the rule is generic
5
+ * (prefixes, octal 4-digit modes, `SudoRefusedError`), rewritten where GNU coreutils and systemd
6
+ * need a different shape than macOS's `install -S` and `launchctl`.
7
+ *
8
+ * ⛔ AN ALLOWLIST OF SHAPES, NOT OF PROGRAMS. `chown` unqualified would let a bug hand any file to
9
+ * any uid; `systemctl` unqualified would let `disable --global` reach every user's units. Each
10
+ * shape below fixes the flags, the operand count and (for install/mv) which paths may appear.
11
+ * ⛔ `install` COPIES ONLY THE FILE THIS RUNNER STAGED, into ONLY the temp path this runner derived
12
+ * for the current write — never argv's own idea of either. Binding both stops a bug (or a future
13
+ * change) from turning this into "install any file the operator can read to any path root can
14
+ * reach".
15
+ * ⛔ GNU `install` WRITES THROUGH ITS DESTINATION (ssh-scripts.ts's header), unlike macOS's, which
16
+ * stages and renames on its own. So there is no `-S`: this allowlist's `install` writes a FRESH
17
+ * temp file (never the live path), and only `mv` — a rename(2), atomic by construction — ever
18
+ * touches the path a provider declared.
19
+ */
20
+ import { NAME as UNIT_NAME } from './unit-form.ts';
21
+ import {
22
+ INSTALL,
23
+ SUDO,
24
+ SudoRefusedError,
25
+ checkPrefixes,
26
+ octalMode,
27
+ prefixOf,
28
+ } from '../launchd/sudo-allowlist.ts';
29
+ import { chownProblem, forceNumericOwner } from './sudo-allowlist-chown.ts';
30
+ import { CHMOD, CHOWN, MKDIR, RMDIR, dirProgramProblem } from './sudo-allowlist-dir.ts';
31
+
32
+ export { INSTALL, SUDO, SudoRefusedError, checkPrefixes, octalMode, prefixOf };
33
+ export { CHMOD, CHOWN, MKDIR, RMDIR } from './sudo-allowlist-dir.ts';
34
+
35
+ /** ★ Absolute program paths, so neither sudo nor the command is looked up on the operator's PATH. */
36
+ export const MV = '/usr/bin/mv';
37
+ export const RM = '/usr/bin/rm';
38
+ export const SYSTEMCTL_ABS = '/usr/bin/systemctl';
39
+
40
+ /** The bare names `directory-lifecycle.ts` and `systemctl.ts` already send through `exec()`. */
41
+ export const DIRECTORY_PROGRAMS: ReadonlySet<string> = new Set([
42
+ 'mkdir',
43
+ 'chmod',
44
+ 'chown',
45
+ 'rmdir',
46
+ ]);
47
+ const DIRECTORY_PROGRAMS_ABS: ReadonlySet<string> = new Set([MKDIR, CHMOD, CHOWN, RMDIR]);
48
+ const ABS_OF: Readonly<Record<string, string>> = {
49
+ chmod: CHMOD,
50
+ chown: CHOWN,
51
+ mkdir: MKDIR,
52
+ rmdir: RMDIR,
53
+ systemctl: SYSTEMCTL_ABS,
54
+ };
55
+ /**
56
+ * The bare argv a provider sends → the absolute argv sudo may run. Unknown programs pass through.
57
+ * ★ `chown`'s owner argument is ALSO rewritten here, to the `+`-forced form `sudo-allowlist-dir.ts`
58
+ * and `sudo-allowlist-chown.ts` now require — `directory-lifecycle.ts` never has to know that
59
+ * syntax exists, and the allowlist checks exactly what will actually reach `sudo`.
60
+ */
61
+ export const canonicalize = (argv: readonly string[]): readonly string[] => {
62
+ const abs = ABS_OF[argv[0] ?? ''];
63
+ const program = abs ?? argv[0];
64
+ if (program === CHOWN) {
65
+ const [owner, ...rest] = argv.slice(1);
66
+ return [CHOWN, owner === undefined ? '' : forceNumericOwner(owner), ...rest];
67
+ }
68
+ return abs === undefined ? argv : [abs, ...argv.slice(1)];
69
+ };
70
+
71
+ const dirOf = (path: string): string => path.slice(0, path.lastIndexOf('/')) || '/';
72
+
73
+ /** The one source `install` may copy, the one temp `install`/`mv` may touch, and the prefixes. */
74
+ export type AllowContext = {
75
+ readonly prefixes: readonly string[];
76
+ readonly staged?: string;
77
+ readonly temp?: string;
78
+ };
79
+
80
+ /**
81
+ * ⛔ NEVER `-o`/`-g` — see sudo-allowlist-chown.ts's header. `install` always writes the fresh temp
82
+ * owned by whoever `sudo` runs it as (root:root), with no name lookup at all; a non-root owner or
83
+ * group is set afterward by a separate, `+`-forced `chown` on that same temp, before `mv`.
84
+ */
85
+ const installProblem = (args: readonly string[], context: AllowContext): string | undefined => {
86
+ const [m, mode, t, dd, source, dest, ...extra] = args;
87
+ if (
88
+ m !== '-m' ||
89
+ mode === undefined ||
90
+ !/^[0-7]{4}$/.test(mode) ||
91
+ t !== '-T' ||
92
+ dd !== '--' ||
93
+ extra.length !== 0
94
+ ) {
95
+ return 'install takes exactly `-m <4 octal digits> -T -- <staged> <temp>`';
96
+ }
97
+ if (source === undefined || source !== context.staged) {
98
+ return 'install copies only the file this runner staged';
99
+ }
100
+ if (dest === undefined || dest !== context.temp) {
101
+ return 'install writes only the derived temp path for this write';
102
+ }
103
+ return prefixOf(dest, context.prefixes) === undefined
104
+ ? 'install destination is not under a declared prefix'
105
+ : undefined;
106
+ };
107
+
108
+ const mvProblem = (args: readonly string[], context: AllowContext): string | undefined => {
109
+ const [f, t, dd, source, dest] = args;
110
+ if (f !== '-f' || t !== '-T' || dd !== '--' || args.length !== 5) {
111
+ return 'mv takes exactly `-f -T -- <temp> <dest>`';
112
+ }
113
+ if (source === undefined || source !== context.temp) {
114
+ return 'mv moves only the temp path this runner just installed to';
115
+ }
116
+ if (dest === undefined || prefixOf(dest, context.prefixes) === undefined) {
117
+ return 'mv destination is not under a declared prefix';
118
+ }
119
+ return dirOf(source) === dirOf(dest)
120
+ ? undefined
121
+ : 'mv must stay inside the destination’s directory';
122
+ };
123
+
124
+ const rmProblem = (args: readonly string[], context: AllowContext): string | undefined => {
125
+ const [force, end, path, ...extra] = args;
126
+ return force === '-f' &&
127
+ end === '--' &&
128
+ extra.length === 0 &&
129
+ path !== undefined &&
130
+ prefixOf(path, context.prefixes) !== undefined
131
+ ? undefined
132
+ : 'rm takes exactly `-f -- <path under a declared prefix>`';
133
+ };
134
+
135
+ const BANNED_SYSTEMCTL_FLAGS: ReadonlySet<string> = new Set([
136
+ '--user',
137
+ '--global',
138
+ '-H',
139
+ '-M',
140
+ '--root',
141
+ ]);
142
+ const UNIT_WRITE_VERBS: ReadonlySet<string> = new Set([
143
+ 'enable',
144
+ 'disable',
145
+ 'start',
146
+ 'stop',
147
+ 'restart',
148
+ ]);
149
+
150
+ const systemctlProblem = (
151
+ args: readonly string[],
152
+ prefixes: readonly string[],
153
+ ): string | undefined => {
154
+ if (args.some((arg) => BANNED_SYSTEMCTL_FLAGS.has(arg))) {
155
+ return 'systemctl --user/--global/-H/-M/--root is never run as root';
156
+ }
157
+ const [verb, ...rest] = args;
158
+ if (verb === 'daemon-reload')
159
+ return rest.length === 0 ? undefined : 'daemon-reload takes no operands';
160
+ if (verb !== undefined && UNIT_WRITE_VERBS.has(verb)) {
161
+ const [dd, unit, ...extra] = rest;
162
+ return dd === '--' && unit !== undefined && UNIT_NAME.test(unit) && extra.length === 0
163
+ ? undefined
164
+ : `${verb} takes exactly \`-- <unit>\``;
165
+ }
166
+ return `systemctl ${String(verb)} is never run as root`;
167
+ };
168
+
169
+ /** `undefined` when `argv` is exactly an allowed privileged shape, else the reason it is not. */
170
+ export const privilegedProblem = (
171
+ argv: readonly string[],
172
+ context: AllowContext,
173
+ ): string | undefined => {
174
+ const [program, ...args] = argv;
175
+ if (program === INSTALL) return installProblem(args, context);
176
+ if (program === MV) return mvProblem(args, context);
177
+ if (program === RM) return rmProblem(args, context);
178
+ if (program === SYSTEMCTL_ABS) return systemctlProblem(args, context.prefixes);
179
+ if (program === CHOWN) return chownProblem(args, context);
180
+ if (program === MKDIR || program === CHMOD || program === RMDIR) {
181
+ return dirProgramProblem(program, args, context.prefixes);
182
+ }
183
+ return `${String(program)} is not on the sudo allowlist`;
184
+ };
185
+
186
+ /** Where one exec goes: run as the operator, run as root through the allowlist, or refused. */
187
+ export type Route =
188
+ | { readonly as: 'operator' }
189
+ | { readonly as: 'root' }
190
+ | { readonly refuse: string };
191
+
192
+ /**
193
+ * ★ ROUTING NEVER TOUCHES THE HOST. mkdir/chmod/chown/rmdir go to root only under a declared
194
+ * prefix — outside one they run as the operator, who may well own that path (H3/rule 7).
195
+ * systemctl's write verbs always ATTEMPT root; the FragmentPath check that can still refuse one
196
+ * needs a host read, so it runs in the runner's `before` hook, not here.
197
+ */
198
+ export const routeExec = (argv: readonly string[], prefixes: readonly string[]): Route => {
199
+ const [program, ...args] = argv;
200
+ if (program === 'sudo' || program?.endsWith('/sudo') === true) {
201
+ return {
202
+ refuse: 'sshSudoRunner never passes a sudo argv through; it elevates only its allowlist',
203
+ };
204
+ }
205
+ if (program === 'systemctl' || program === SYSTEMCTL_ABS) {
206
+ if (args.some((arg) => BANNED_SYSTEMCTL_FLAGS.has(arg))) {
207
+ return { refuse: 'sshSudoRunner refuses systemctl --user/--global/-H/-M/--root' };
208
+ }
209
+ const [verb] = args;
210
+ return verb === 'daemon-reload' || (verb !== undefined && UNIT_WRITE_VERBS.has(verb))
211
+ ? { as: 'root' }
212
+ : { as: 'operator' };
213
+ }
214
+ // ★ Both spellings route the same way — a caller that happened to pass the absolute program
215
+ // (nothing in this repo does today) must not silently fall through to the operator branch.
216
+ if (DIRECTORY_PROGRAMS.has(program ?? '') || DIRECTORY_PROGRAMS_ABS.has(program ?? '')) {
217
+ const path = args[args.length - 1];
218
+ return path !== undefined && prefixOf(path, prefixes) !== undefined
219
+ ? { as: 'root' }
220
+ : { as: 'operator' };
221
+ }
222
+ return { as: 'operator' };
223
+ };