@homeflare/alchemy 0.25.0 β 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.
- package/dist/launchd/index.js +51 -47
- package/dist/launchd/index.js.map +5 -4
- package/dist/launchd/sudo-guard-mode.d.ts +62 -0
- package/dist/launchd/sudo-guard-mode.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +1 -38
- package/dist/launchd/sudo-guard.d.ts.map +1 -1
- package/dist/linux/fake-linux-host.d.ts.map +1 -1
- package/dist/linux/fake-sudo-root.d.ts +26 -0
- package/dist/linux/fake-sudo-root.d.ts.map +1 -0
- package/dist/linux/fake-sudo.d.ts +56 -0
- package/dist/linux/fake-sudo.d.ts.map +1 -0
- package/dist/linux/fake-systemd.d.ts +15 -0
- package/dist/linux/fake-systemd.d.ts.map +1 -1
- package/dist/linux/index.d.ts +2 -0
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +1151 -8
- package/dist/linux/index.js.map +24 -7
- package/dist/linux/sudo-allowlist-chown.d.ts +12 -0
- package/dist/linux/sudo-allowlist-chown.d.ts.map +1 -0
- package/dist/linux/sudo-allowlist-dir.d.ts +6 -0
- package/dist/linux/sudo-allowlist-dir.d.ts.map +1 -0
- package/dist/linux/sudo-allowlist.d.ts +40 -0
- package/dist/linux/sudo-allowlist.d.ts.map +1 -0
- package/dist/linux/sudo-exec.d.ts +31 -0
- package/dist/linux/sudo-exec.d.ts.map +1 -0
- package/dist/linux/sudo-guard.d.ts +36 -0
- package/dist/linux/sudo-guard.d.ts.map +1 -0
- package/dist/linux/sudo-runner.d.ts +37 -0
- package/dist/linux/sudo-runner.d.ts.map +1 -0
- package/dist/linux/sudo-stage.d.ts +22 -0
- package/dist/linux/sudo-stage.d.ts.map +1 -0
- package/dist/linux/sudo-write.d.ts +25 -0
- package/dist/linux/sudo-write.d.ts.map +1 -0
- package/dist/linux/unit-form.d.ts +43 -0
- package/dist/linux/unit-form.d.ts.map +1 -1
- package/dist/linux/unit-lifecycle.d.ts.map +1 -1
- package/dist/linux/unit-settle.d.ts.map +1 -1
- package/dist/litellm/fake-litellm.d.ts.map +1 -1
- package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -1
- package/docs/launchd-sudo.md +12 -3
- package/docs/linux-host.md +5 -3
- package/docs/linux-sudo.md +187 -0
- package/package.json +1 -1
- package/src/launchd/sudo-guard-mode.ts +111 -0
- package/src/launchd/sudo-guard.ts +3 -79
- package/src/launchd/sudo-modes.test.ts +38 -1
- package/src/linux/fake-linux-host.ts +2 -0
- package/src/linux/fake-sudo-root.ts +157 -0
- package/src/linux/fake-sudo.ts +142 -0
- package/src/linux/fake-systemd.ts +36 -2
- package/src/linux/index.ts +2 -0
- package/src/linux/sudo-allowlist-chown.ts +71 -0
- package/src/linux/sudo-allowlist-dir.ts +103 -0
- package/src/linux/sudo-allowlist.test.ts +252 -0
- package/src/linux/sudo-allowlist.ts +223 -0
- package/src/linux/sudo-exec.ts +118 -0
- package/src/linux/sudo-guard.test.ts +136 -0
- package/src/linux/sudo-guard.ts +142 -0
- package/src/linux/sudo-lifecycle.test.ts +151 -0
- package/src/linux/sudo-runner.test.ts +206 -0
- package/src/linux/sudo-runner.ts +164 -0
- package/src/linux/sudo-stage.ts +50 -0
- package/src/linux/sudo-write.ts +167 -0
- package/src/linux/unit-activating.test.ts +164 -0
- package/src/linux/unit-form.ts +53 -1
- package/src/linux/unit-lifecycle.ts +9 -2
- package/src/linux/unit-settle.ts +8 -4
- package/src/litellm/fake-litellm.ts +2 -0
- package/src/proxmox/client.test.ts +17 -3
- package/src/proxmox/fake-pve-lxc.ts +10 -1
|
@@ -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
|
+
};
|
|
@@ -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
|
+
};
|