@homeflare/alchemy 0.55.0 → 0.56.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 +4 -2
- package/dist/launchd/index.js.map +3 -3
- package/dist/launchd/sudo-guard-mode.d.ts +10 -4
- package/dist/launchd/sudo-guard-mode.d.ts.map +1 -1
- package/dist/linux/fake-sudo-root.d.ts.map +1 -1
- package/dist/linux/fake-sudo.d.ts +9 -1
- package/dist/linux/fake-sudo.d.ts.map +1 -1
- package/dist/linux/index.js +15 -8
- package/dist/linux/index.js.map +6 -6
- package/dist/linux/sudo-guard.d.ts +10 -1
- package/dist/linux/sudo-guard.d.ts.map +1 -1
- package/dist/linux/sudo-runner.d.ts +3 -1
- package/dist/linux/sudo-runner.d.ts.map +1 -1
- package/dist/linux/sudo-write.d.ts +4 -1
- package/dist/linux/sudo-write.d.ts.map +1 -1
- package/docs/linux-sudo-review-2026-09-23.md +27 -0
- package/docs/linux-sudo.md +28 -40
- package/package.json +1 -1
- package/src/launchd/sudo-guard-mode.ts +11 -3
- package/src/linux/fake-sudo-root.ts +15 -0
- package/src/linux/fake-sudo.ts +20 -1
- package/src/linux/sudo-guard.ts +22 -1
- package/src/linux/sudo-runner-privileged-read.test.ts +109 -1
- package/src/linux/sudo-runner.ts +5 -3
- package/src/linux/sudo-write.ts +5 -1
- package/src/talos/talosctl.test.ts +6 -4
|
@@ -31,6 +31,15 @@ export declare const assertGuardedChain: (base: HostRunner, prefix: string, path
|
|
|
31
31
|
* The mode/owner and read-back checks for a file write under `prefix`, plus the path chain.
|
|
32
32
|
* ★ Reads only, as the operator — the same checks run at plan time (`checkWrite`) and before the
|
|
33
33
|
* install/mv pair. `expect` is `'file-or-absent'`: a write may create or replace.
|
|
34
|
+
* ★ `privilegedReads` is the runner's exact-path elevated-read set (sudo-runner.ts): a file on it
|
|
35
|
+
* is read back through `sudo -n base64`, so it cannot be stranded by its own mode — every
|
|
36
|
+
* other refusal below applies to it unchanged.
|
|
37
|
+
* ⛔ A LISTED FILE MAY NEVER BE HANDED TO A NON-ROOT UID, whatever its mode: its read back is
|
|
38
|
+
* the elevated one, which refuses anything but a root-owned regular file, so a handoff with
|
|
39
|
+
* operator-readable other bits (0644 uid 1001) would sail through the plain read-back check,
|
|
40
|
+
* land, and die at EVERY later read — the stranding this guard exists to prevent (glm seat
|
|
41
|
+
* round, 2026-10-18). Refused up front instead. An omitted owner is root under a prefix, so
|
|
42
|
+
* plain root writes are untouched.
|
|
34
43
|
*/
|
|
35
|
-
export declare const assertInstallable: (base: HostRunner, prefix: string, path: string, options: WriteOptions, groups: readonly number[] | undefined) => Promise<void>;
|
|
44
|
+
export declare const assertInstallable: (base: HostRunner, prefix: string, path: string, options: WriteOptions, groups: readonly number[] | undefined, privilegedReads?: ReadonlySet<string>) => Promise<void>;
|
|
36
45
|
//# sourceMappingURL=sudo-guard.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sudo-guard.d.ts","sourceRoot":"","sources":["../../src/linux/sudo-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAC/E,OAAO,EACL,SAAS,EACT,kBAAkB,EAClB,WAAW,EACX,OAAO,EACP,WAAW,EACX,cAAc,EACf,MAAM,0BAA0B,CAAC;AAIlC,OAAO,EAAE,SAAS,EAAE,kBAAkB,EAAE,WAAW,EAAE,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,CAAC;AAgB5F,2EAA2E;AAC3E,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,gBAAgB,GAAG,WAAW,GAAG,QAAQ,CAAC;AAmB7E;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,SACvB,UAAU,UACR,MAAM,QACR,MAAM,UACJ,WAAW,KAClB,OAAO,CAAC,QAAQ,GAAG,SAAS,CAyC9B,CAAC;AAEF
|
|
1
|
+
{"version":3,"file":"sudo-guard.d.ts","sourceRoot":"","sources":["../../src/linux/sudo-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAC/E,OAAO,EACL,SAAS,EACT,kBAAkB,EAClB,WAAW,EACX,OAAO,EACP,WAAW,EACX,cAAc,EACf,MAAM,0BAA0B,CAAC;AAIlC,OAAO,EAAE,SAAS,EAAE,kBAAkB,EAAE,WAAW,EAAE,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,CAAC;AAgB5F,2EAA2E;AAC3E,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,gBAAgB,GAAG,WAAW,GAAG,QAAQ,CAAC;AAmB7E;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,SACvB,UAAU,UACR,MAAM,QACR,MAAM,UACJ,WAAW,KAClB,OAAO,CAAC,QAAQ,GAAG,SAAS,CAyC9B,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,iBAAiB,SACtB,UAAU,UACR,MAAM,QACR,MAAM,WACH,YAAY,UACb,SAAS,MAAM,EAAE,GAAG,SAAS,oBACnB,WAAW,CAAC,MAAM,CAAC,KACpC,OAAO,CAAC,IAAI,CAsBd,CAAC"}
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* is the "no silent sudo" failure this exists to prevent.
|
|
9
9
|
* ⛔ EVERY PRIVILEGED ARGV IS LOGGED BEFORE IT RUNS, and a file's bytes never reach argv or the
|
|
10
10
|
* log: they travel through the staged file (sudo-stage.ts) alone.
|
|
11
|
-
* ⛔ A PLAN
|
|
11
|
+
* ⛔ A PLAN ELEVATES EXACTLY ONE KIND OF CALL: the `privilegedReads` file's own read (below) —
|
|
12
|
+
* the read a plan diff needs on a declared root-owned file. Every other read, and every
|
|
13
|
+
* `checkWrite` host probe (`id -G`, `ls`, `stat`), runs through `base` — the plain
|
|
12
14
|
* `sshRunner()` — never through sudo. Nothing here probes sudo at construction.
|
|
13
15
|
* ★ PROMISES, NOT EFFECTS, ON PURPOSE — the same divergence `../launchd/runner.ts` documents
|
|
14
16
|
* (H3 in the provider-standard skill): a consumer's own runner is a plain async implementation.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sudo-runner.d.ts","sourceRoot":"","sources":["../../src/linux/sudo-runner.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"sudo-runner.d.ts","sourceRoot":"","sources":["../../src/linux/sudo-runner.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,OAAO,EAAE,KAAK,gBAAgB,EAAa,MAAM,iBAAiB,CAAC;AAEnE,OAAO,EAUL,gBAAgB,EAKjB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAAE,KAAK,MAAM,EAAe,MAAM,iBAAiB,CAAC;AAG3D,OAAO,EAAE,gBAAgB,EAAE,CAAC;AAE5B,MAAM,MAAM,oBAAoB,GAAG,gBAAgB,GAAG;IACpD,mFAAmF;IACnF,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,8EAA8E;IAC9E,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC9C,CAAC;AAEF,iGAAiG;AACjG,MAAM,MAAM,WAAW,GAAG;IACxB,6FAA6F;IAC7F,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACvD,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,QAAQ,CAAC,eAAe,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC9C,CAAC;AAEF,eAAO,MAAM,MAAM,oBAAoB,CAAC;AAExC,sGAAsG;AACtG,eAAO,MAAM,oBAAoB,WAAW,SAAS,MAAM,EAAE,KAAQ,WAAW,CAAC,MAAM,CActF,CAAC;AAkBF,eAAO,MAAM,iBAAiB,eAAgB,SAAS,MAAM,EAAE,QAAQ,WAAW,KAAG,UAmFpF,CAAC;AAsBF,gGAAgG;AAChG,eAAO,MAAM,aAAa,YAAmB,oBAAoB,KAAG,OAAO,CAAC,UAAU,CAarF,CAAC"}
|
|
@@ -11,8 +11,11 @@ export type Elevate = (argv: readonly string[], context?: {
|
|
|
11
11
|
* Plan-time and write-time share this: valid mode, the path chain, mode/owner refusals and the
|
|
12
12
|
* read-back — everything a write can be refused for before anything is staged or run as root.
|
|
13
13
|
* `undefined` outside every prefix, where the caller falls back to `base` directly.
|
|
14
|
+
* ★ `privilegedReads` is the runner's exact-path elevated-read set: a file on it is read back as
|
|
15
|
+
* root (sudo-runner.ts's readFile), so the read-back refusal does not apply to it — the other
|
|
16
|
+
* checks in assertInstallable still do.
|
|
14
17
|
*/
|
|
15
|
-
export declare const vetWrite: (base: HostRunner, prefixes: readonly string[], path: string, options: WriteOptions) => Promise<{
|
|
18
|
+
export declare const vetWrite: (base: HostRunner, prefixes: readonly string[], path: string, options: WriteOptions, privilegedReads?: ReadonlySet<string>) => Promise<{
|
|
16
19
|
readonly mode: string;
|
|
17
20
|
readonly prefix: string;
|
|
18
21
|
} | undefined>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sudo-write.d.ts","sourceRoot":"","sources":["../../src/linux/sudo-write.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAGjF,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAK9C,4FAA4F;AAC5F,eAAO,MAAM,UAAU,SAAU,MAAM,KAAG,MACoC,CAAC;AAgC/E,oGAAoG;AACpG,MAAM,MAAM,OAAO,GAAG,CACpB,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,CAAC,EAAE;IAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,EAC9D,MAAM,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,KAC5B,OAAO,CAAC,UAAU,CAAC,CAAC;AAEzB
|
|
1
|
+
{"version":3,"file":"sudo-write.d.ts","sourceRoot":"","sources":["../../src/linux/sudo-write.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAGjF,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAK9C,4FAA4F;AAC5F,eAAO,MAAM,UAAU,SAAU,MAAM,KAAG,MACoC,CAAC;AAgC/E,oGAAoG;AACpG,MAAM,MAAM,OAAO,GAAG,CACpB,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,CAAC,EAAE;IAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,EAC9D,MAAM,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,KAC5B,OAAO,CAAC,UAAU,CAAC,CAAC;AAEzB;;;;;;;GAOG;AACH,eAAO,MAAM,QAAQ,SACb,UAAU,YACN,SAAS,MAAM,EAAE,QACrB,MAAM,WACH,YAAY,oBACH,WAAW,CAAC,MAAM,CAAC,KACpC,OAAO,CAAC;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CASxE,CAAC;AAsBF;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,SACrB,UAAU,WACP,OAAO,SACT,CAAC,KAAK,EAAE,UAAU,KAAK,OAAO,CAAC,MAAM,CAAC,QACvC,MAAM,SACL,UAAU,WACR,YAAY,QACf,MAAM,KACX,OAAO,CAAC,IAAI,CAyCd,CAAC"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# The 2026-09-23 adversarial review of the linux sudo runner
|
|
2
|
+
|
|
3
|
+
Extracted from [linux-sudo.md](./linux-sudo.md) to keep that doc under the 200-line
|
|
4
|
+
cap. The findings it records are live rules of the runner, not history.
|
|
5
|
+
|
|
6
|
+
🔴 **Adversarial review, 2026-09-23, two rounds — everything found is fixed
|
|
7
|
+
and tested.** Round 1: a directory this runner elevates was checked for
|
|
8
|
+
SHAPE only, never mode or owner. `mkdir -m 0777` and `chown <non-root uid>`
|
|
9
|
+
both passed untouched, so a stack could declare `HostDirectory({ path:
|
|
10
|
+
'/etc/systemd/system/x.service.d', mode: 0o777 })`, drop an `ExecStart=`
|
|
11
|
+
override into it, and this runner's own next `daemon-reload` + `restart`
|
|
12
|
+
would run it as root. Fixed: a directory this runner elevates is now always
|
|
13
|
+
root-owned and never setuid/setgid or group/other-writable, unconditionally
|
|
14
|
+
— a directory's OWNER always has write access through the owner bits alone,
|
|
15
|
+
so no mode restriction alone could make a non-root-owned directory safe
|
|
16
|
+
here. The same round tightened the shared `modeProblem` check
|
|
17
|
+
(`../launchd/sudo-guard.ts`, used by both platforms): it exempted a file
|
|
18
|
+
merely because its `uid` was non-root, missing that `{uid: 501, gid: 0,
|
|
19
|
+
mode: 0o2775}` — setgid to root's own group — reaches the same escalation
|
|
20
|
+
through the group instead. Round 2 found the round-1 `modeProblem` fix was
|
|
21
|
+
itself incomplete (an OMITTED `gid`, not just an explicit `0`, defaults to
|
|
22
|
+
root's own group under `install` — see the section above), the masked-unit
|
|
23
|
+
and FragmentPath-scope gaps documented above, `install`'s own numeric-id
|
|
24
|
+
ambiguity (also above), and a swallowed cleanup-`rm` failure (now included
|
|
25
|
+
in the thrown error, with the leftover path named). All covered by tests:
|
|
26
|
+
`sudo-allowlist.test.ts`, `sudo-lifecycle.test.ts`, `sudo-runner.test.ts`,
|
|
27
|
+
`../launchd/sudo-modes.test.ts`.
|
package/docs/linux-sudo.md
CHANGED
|
@@ -60,18 +60,19 @@ staging directory is removed either way.
|
|
|
60
60
|
|
|
61
61
|
Each is `/usr/bin/sudo -n -- <argv>`, every program by absolute path.
|
|
62
62
|
|
|
63
|
-
| when | argv
|
|
64
|
-
| ------------------------------------ |
|
|
65
|
-
| a file under a prefix — stage→temp | `/usr/bin/install -m <0644> -T -- <staged> <temp>`
|
|
66
|
-
| its owner, if not root:root | `/usr/bin/chown +<uid>[:+<gid>] -- <temp>` — numeric, `+`-forced, bound to `<temp>`
|
|
67
|
-
| a file under a prefix — temp→dest | `/usr/bin/mv -f -T -- <temp> <dest>`
|
|
68
|
-
| a file under a prefix is removed | `/usr/bin/rm -f -- <path>`
|
|
69
|
-
| a directory under a prefix is made | `/usr/bin/mkdir -m <octal> -- <path>` — never setuid/setgid or group/other-write
|
|
70
|
-
| its mode is fixed | `/usr/bin/chmod <octal> -- <path>` — same restriction
|
|
71
|
-
| its owner is fixed | `/usr/bin/chown <owner> -- <path>` — **root only**: `+0`, `:+0` or `+0:+0`
|
|
72
|
-
| it is removed | `/usr/bin/rmdir -- <path>`
|
|
73
|
-
| after any unit-file write/removal | `/usr/bin/systemctl daemon-reload`
|
|
74
|
-
| a unit is enabled/disabled/started/… | `/usr/bin/systemctl <verb> -- <unit>`
|
|
63
|
+
| when | argv |
|
|
64
|
+
| ------------------------------------ | --------------------------------------------------------------------------------------------- |
|
|
65
|
+
| a file under a prefix — stage→temp | `/usr/bin/install -m <0644> -T -- <staged> <temp>` |
|
|
66
|
+
| its owner, if not root:root | `/usr/bin/chown +<uid>[:+<gid>] -- <temp>` — numeric, `+`-forced, bound to `<temp>` |
|
|
67
|
+
| a file under a prefix — temp→dest | `/usr/bin/mv -f -T -- <temp> <dest>` |
|
|
68
|
+
| a file under a prefix is removed | `/usr/bin/rm -f -- <path>` |
|
|
69
|
+
| a directory under a prefix is made | `/usr/bin/mkdir -m <octal> -- <path>` — never setuid/setgid or group/other-write |
|
|
70
|
+
| its mode is fixed | `/usr/bin/chmod <octal> -- <path>` — same restriction |
|
|
71
|
+
| its owner is fixed | `/usr/bin/chown <owner> -- <path>` — **root only**: `+0`, `:+0` or `+0:+0` |
|
|
72
|
+
| it is removed | `/usr/bin/rmdir -- <path>` |
|
|
73
|
+
| after any unit-file write/removal | `/usr/bin/systemctl daemon-reload` |
|
|
74
|
+
| a unit is enabled/disabled/started/… | `/usr/bin/systemctl <verb> -- <unit>` |
|
|
75
|
+
| a `privilegedReads` file is read | `/usr/bin/base64 -- <path>` — exact listed path, root-owned regular file, or the read refuses |
|
|
75
76
|
|
|
76
77
|
`enable`/`disable`/`start`/`stop`/`restart` elevate only when the unit is not
|
|
77
78
|
**masked** (`LoadState`, read alongside `FragmentPath` in the same call —
|
|
@@ -103,7 +104,7 @@ know that syntax exists.
|
|
|
103
104
|
|
|
104
105
|
## What stays as the operator
|
|
105
106
|
|
|
106
|
-
- Every read: `stat`, `readFile`, user/group lookups, `systemctl show
|
|
107
|
+
- Every read: `stat`, `readFile`, user/group lookups, `systemctl show` — with one exact exception: a file on the runner's `privilegedReads` list is read with `sudo -n base64` (the plan-diff read a declared root-owned file needs; see the table above). Every other path, and every `checkWrite` host probe (`id -G`, `ls`, `stat`), stays the operator's own.
|
|
107
108
|
- `checkWrite` — the plan-time half of a write, run at every plan so a
|
|
108
109
|
refusal surfaces before any resource is applied, never at the write.
|
|
109
110
|
- Files and directories outside every prefix (a foreign owner there is a
|
|
@@ -111,10 +112,11 @@ know that syntax exists.
|
|
|
111
112
|
- `ls -ldn` and `mktemp -d` — the guard's chain read and the write's own
|
|
112
113
|
staging, both unprivileged (`src/linux/sudo-listing.ts`, `sudo-stage.ts`).
|
|
113
114
|
|
|
114
|
-
★ **So a plan
|
|
115
|
-
`sshSudoRunner()` runs one combined,
|
|
116
|
-
absolute programs above, so a missing
|
|
117
|
-
container fails with a clear message at
|
|
115
|
+
★ **So a plan calls sudo for exactly one thing** — reading a `privilegedReads`
|
|
116
|
+
file — and construction never does: `sshSudoRunner()` runs one combined,
|
|
117
|
+
read-only `test -x` for the nine absolute programs above, so a missing
|
|
118
|
+
coreutils package or a systemd-free container fails with a clear message at
|
|
119
|
+
construction — never a sudo probe.
|
|
118
120
|
|
|
119
121
|
## The host guard
|
|
120
122
|
|
|
@@ -144,31 +146,17 @@ prefix-sibling (`/usr/local/bin-x` is not under `/usr/local/bin`), or a
|
|
|
144
146
|
`chown` by name, a bare digit, or any non-root id; `systemctl mask`/`--user`;
|
|
145
147
|
a masked unit, any verb; `enable`/`start`/`restart` on a unit with no unit
|
|
146
148
|
file; a root-owned file that would be group- or world-writable, setuid or
|
|
147
|
-
setgid; a file the
|
|
148
|
-
`Remote.File` read every write back to
|
|
149
|
+
setgid; a file NOT on the `privilegedReads` list that the operator could not
|
|
150
|
+
read back (both `Systemd.Unit` and `Remote.File` read every write back to
|
|
151
|
+
compare digests); a listed file handed to a non-root uid, whatever its mode
|
|
152
|
+
— the elevated read-back refuses anything but a root-owned regular file, so
|
|
153
|
+
the handoff would land and then fail every read.
|
|
149
154
|
|
|
150
155
|
🔴 **Adversarial review, 2026-09-23, two rounds — everything found is fixed
|
|
151
|
-
and tested
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
override into it, and this runner's own next `daemon-reload` + `restart`
|
|
156
|
-
would run it as root. Fixed: a directory this runner elevates is now always
|
|
157
|
-
root-owned and never setuid/setgid or group/other-writable, unconditionally
|
|
158
|
-
— a directory's OWNER always has write access through the owner bits alone,
|
|
159
|
-
so no mode restriction alone could make a non-root-owned directory safe
|
|
160
|
-
here. The same round tightened the shared `modeProblem` check
|
|
161
|
-
(`../launchd/sudo-guard.ts`, used by both platforms): it exempted a file
|
|
162
|
-
merely because its `uid` was non-root, missing that `{uid: 501, gid: 0,
|
|
163
|
-
mode: 0o2775}` — setgid to root's own group — reaches the same escalation
|
|
164
|
-
through the group instead. Round 2 found the round-1 `modeProblem` fix was
|
|
165
|
-
itself incomplete (an OMITTED `gid`, not just an explicit `0`, defaults to
|
|
166
|
-
root's own group under `install` — see the section above), the masked-unit
|
|
167
|
-
and FragmentPath-scope gaps documented above, `install`'s own numeric-id
|
|
168
|
-
ambiguity (also above), and a swallowed cleanup-`rm` failure (now included
|
|
169
|
-
in the thrown error, with the leftover path named). All covered by tests:
|
|
170
|
-
`sudo-allowlist.test.ts`, `sudo-lifecycle.test.ts`, `sudo-runner.test.ts`,
|
|
171
|
-
`../launchd/sudo-modes.test.ts`.
|
|
156
|
+
and tested** — the full record (the directory shape-only gap, the shared `modeProblem`
|
|
157
|
+
escapes, the omitted-`gid` default, the masked-unit and `FragmentPath` scope gaps,
|
|
158
|
+
`install`'s numeric-id ambiguity, the swallowed cleanup failure) lives in
|
|
159
|
+
[linux-sudo-review-2026-09-23.md](./linux-sudo-review-2026-09-23.md).
|
|
172
160
|
|
|
173
161
|
Every refusal throws `SudoRefusedError`: nothing ran as root. A privileged
|
|
174
162
|
command that ran and failed throws a plain `Error` with its exit code.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.56.0",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
@@ -72,9 +72,15 @@ export const canRead = (
|
|
|
72
72
|
/**
|
|
73
73
|
* ⛔ REFUSE A FILE THE DEPLOYING USER COULD NOT READ BACK, before writing it. Both providers read
|
|
74
74
|
* every write back to verify it (host-file-lifecycle.ts, job-lifecycle.ts) and read it again at
|
|
75
|
-
* every plan, as the deploying user;
|
|
76
|
-
* `0600`
|
|
77
|
-
*
|
|
75
|
+
* every plan, as the deploying user; a runner that elevates writes but no reads strands a
|
|
76
|
+
* root-only `0600` on disk — installed, failing the read-back with EACCES, with no state, so
|
|
77
|
+
* every later plan would fail too.
|
|
78
|
+
* ★ `readAsRoot` IS THE ONE EXCEPTION, and it is not this guard's to grant: the caller passes it
|
|
79
|
+
* only when the RUNNER reads this exact path back through a privileged read (the linux
|
|
80
|
+
* sshSudoRunner's `privilegedReads` list names the file, so its readFile goes through
|
|
81
|
+
* `sudo -n base64`). The read-back then cannot fail with EACCES whatever the mode is, and the
|
|
82
|
+
* refusal would be false; every other check — setuid/setgid, group-writable, the path chain —
|
|
83
|
+
* still applies to that file exactly as before.
|
|
78
84
|
* ★ An omitted owner is root (under a prefix the runner writes as root). An omitted group is the
|
|
79
85
|
* parent directory's, which is the group a new file gets on macOS.
|
|
80
86
|
* ⚠️ Unknown groups never refuse: the read-back after the write stays the final judge.
|
|
@@ -84,7 +90,9 @@ export const assertReadableBack = async (
|
|
|
84
90
|
path: string,
|
|
85
91
|
options: WriteOptions,
|
|
86
92
|
groups: readonly number[] | undefined,
|
|
93
|
+
readAsRoot = false,
|
|
87
94
|
): Promise<void> => {
|
|
95
|
+
if (readAsRoot) return;
|
|
88
96
|
const operator = base.effectiveUid();
|
|
89
97
|
const parent = path.slice(0, path.lastIndexOf('/')) || '/';
|
|
90
98
|
const gid = options.gid ?? (await base.stat(parent))?.gid ?? 0;
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* separate "staged bytes" side-channel to model, unlike the Mac fake's local temp files.
|
|
14
14
|
*/
|
|
15
15
|
import type { ExecResult, FileStat } from '../launchd/runner.ts';
|
|
16
|
+
import { BASE64 } from './sudo-runner.ts';
|
|
16
17
|
import { CHMOD, CHOWN, INSTALL, MKDIR, MV, RM, RMDIR, SYSTEMCTL_ABS } from './sudo-allowlist.ts';
|
|
17
18
|
import type { fakeLinuxHost } from './fake-linux-host.ts';
|
|
18
19
|
|
|
@@ -113,6 +114,20 @@ const fileRoot = (
|
|
|
113
114
|
if (path !== undefined) fake.files.delete(path);
|
|
114
115
|
return ok();
|
|
115
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* ★ `sudo -n base64 -- <path>` — the elevated read itself (sudo-runner.ts's readFile), so a
|
|
119
|
+
* test can join the two halves: a hidden-mode file just written through the skipped guard
|
|
120
|
+
* reads back AS ROOT here, never EACCES. Refuses exactly what the runner's own pre-elevation
|
|
121
|
+
* check refuses: a root-owned REGULAR file, nothing else.
|
|
122
|
+
*/
|
|
123
|
+
if (program === BASE64) {
|
|
124
|
+
const [, path] = args;
|
|
125
|
+
const entry = path === undefined ? undefined : fake.files.get(path);
|
|
126
|
+
if (entry === undefined || entry.kind !== 'file' || entry.uid !== 0) {
|
|
127
|
+
return fail(1, `base64: '${String(path)}': No such file or directory`);
|
|
128
|
+
}
|
|
129
|
+
return ok(`${Buffer.from(entry.bytes).toString('base64')}\n`);
|
|
130
|
+
}
|
|
116
131
|
return undefined;
|
|
117
132
|
};
|
|
118
133
|
|
package/src/linux/fake-sudo.ts
CHANGED
|
@@ -59,6 +59,14 @@ export type FakeSudoHostOptions = {
|
|
|
59
59
|
readonly hostFactory?: (options: FakeLinuxOptions) => ReturnType<typeof fakeLinuxHost>;
|
|
60
60
|
/** The runner's own declared prefixes. @default PREFIXES */
|
|
61
61
|
readonly prefixes?: readonly string[];
|
|
62
|
+
/** The runner's exact-path elevated reads (`sshSudoRunner`'s `privilegedReads`). */
|
|
63
|
+
readonly privilegedReads?: readonly string[];
|
|
64
|
+
/**
|
|
65
|
+
* What `/usr/bin/id -G` answers (sudo-guard-mode.ts's `operatorGroups`). Unset, the fake has
|
|
66
|
+
* no `id` and `operatorGroups` reads `undefined` — "unknown groups never refuse" — which is
|
|
67
|
+
* what every pre-existing test relied on; set it to make the read-back refusal decidable.
|
|
68
|
+
*/
|
|
69
|
+
readonly operatorGroups?: readonly number[];
|
|
62
70
|
};
|
|
63
71
|
|
|
64
72
|
/**
|
|
@@ -69,7 +77,12 @@ export type FakeSudoHostOptions = {
|
|
|
69
77
|
export const fakeSudoHost = (
|
|
70
78
|
extraDirs: Record<string, number> = {},
|
|
71
79
|
acl: ReadonlySet<string> = new Set(),
|
|
72
|
-
{
|
|
80
|
+
{
|
|
81
|
+
hostFactory = fakeLinuxHost,
|
|
82
|
+
prefixes = PREFIXES,
|
|
83
|
+
privilegedReads,
|
|
84
|
+
operatorGroups: idGroups,
|
|
85
|
+
}: FakeSudoHostOptions = {},
|
|
73
86
|
) => {
|
|
74
87
|
const dirs: Record<string, number> = {};
|
|
75
88
|
for (const prefix of prefixes) dirs[prefix] = 0;
|
|
@@ -131,6 +144,11 @@ export const fakeSudoHost = (
|
|
|
131
144
|
return handleMktemp();
|
|
132
145
|
}
|
|
133
146
|
if (argv[0] === 'rm' || argv[0] === '/usr/bin/rm') return handleRm(argv);
|
|
147
|
+
// ★ Only modelled when a test asks for it: unset, `id` stays unknown and the read-back
|
|
148
|
+
// guard keeps its "unknown groups never refuse" behavior (see the option's docs).
|
|
149
|
+
if (argv[0] === '/usr/bin/id' && argv[1] === '-G' && idGroups !== undefined) {
|
|
150
|
+
return ok(`${idGroups.join(' ')}\n`);
|
|
151
|
+
}
|
|
134
152
|
if (argv[0] === '/usr/bin/systemctl')
|
|
135
153
|
return fake.runner.exec(['systemctl', ...argv.slice(1)]);
|
|
136
154
|
if (argv[0] !== SUDO) return fake.runner.exec(argv);
|
|
@@ -146,6 +164,7 @@ export const fakeSudoHost = (
|
|
|
146
164
|
const runner = makeSshSudoRunner(prefixes, {
|
|
147
165
|
base,
|
|
148
166
|
log: (line) => logs.push(line),
|
|
167
|
+
...(privilegedReads === undefined ? {} : { privilegedReads }),
|
|
149
168
|
// ★ The real stager, unmodified: it only ever calls `base.exec`/`base.writeFileAtomic`, both
|
|
150
169
|
// of which this fake's `mktemp`/`ls`/operator-level paths already answer.
|
|
151
170
|
stage: stageRemote(base),
|
package/src/linux/sudo-guard.ts
CHANGED
|
@@ -121,6 +121,15 @@ export const assertGuardedChain = async (
|
|
|
121
121
|
* The mode/owner and read-back checks for a file write under `prefix`, plus the path chain.
|
|
122
122
|
* ★ Reads only, as the operator — the same checks run at plan time (`checkWrite`) and before the
|
|
123
123
|
* install/mv pair. `expect` is `'file-or-absent'`: a write may create or replace.
|
|
124
|
+
* ★ `privilegedReads` is the runner's exact-path elevated-read set (sudo-runner.ts): a file on it
|
|
125
|
+
* is read back through `sudo -n base64`, so it cannot be stranded by its own mode — every
|
|
126
|
+
* other refusal below applies to it unchanged.
|
|
127
|
+
* ⛔ A LISTED FILE MAY NEVER BE HANDED TO A NON-ROOT UID, whatever its mode: its read back is
|
|
128
|
+
* the elevated one, which refuses anything but a root-owned regular file, so a handoff with
|
|
129
|
+
* operator-readable other bits (0644 uid 1001) would sail through the plain read-back check,
|
|
130
|
+
* land, and die at EVERY later read — the stranding this guard exists to prevent (glm seat
|
|
131
|
+
* round, 2026-10-18). Refused up front instead. An omitted owner is root under a prefix, so
|
|
132
|
+
* plain root writes are untouched.
|
|
124
133
|
*/
|
|
125
134
|
export const assertInstallable = async (
|
|
126
135
|
base: HostRunner,
|
|
@@ -128,6 +137,7 @@ export const assertInstallable = async (
|
|
|
128
137
|
path: string,
|
|
129
138
|
options: WriteOptions,
|
|
130
139
|
groups: readonly number[] | undefined,
|
|
140
|
+
privilegedReads?: ReadonlySet<string>,
|
|
131
141
|
): Promise<void> => {
|
|
132
142
|
const mode = modeProblem(options);
|
|
133
143
|
if (mode !== undefined) {
|
|
@@ -137,6 +147,17 @@ export const assertInstallable = async (
|
|
|
137
147
|
'real owner. Nothing ran as root.',
|
|
138
148
|
);
|
|
139
149
|
}
|
|
150
|
+
const listed = privilegedReads?.has(path) === true;
|
|
151
|
+
const uid = options.uid ?? 0;
|
|
152
|
+
if (listed && uid !== 0) {
|
|
153
|
+
throw refuse(
|
|
154
|
+
path,
|
|
155
|
+
`is on the runner's privilegedReads list, so its read back is the elevated one — which ` +
|
|
156
|
+
`refuses anything but a root-owned regular file. Handing it to uid ${String(uid)} would ` +
|
|
157
|
+
'land and then fail every read. Keep it root-owned, or take it off the list. Nothing ' +
|
|
158
|
+
'ran as root.',
|
|
159
|
+
);
|
|
160
|
+
}
|
|
140
161
|
await assertGuardedChain(base, prefix, path, 'file-or-absent');
|
|
141
|
-
await assertReadableBack(base, path, options, groups);
|
|
162
|
+
await assertReadableBack(base, path, options, groups, listed && uid === 0);
|
|
142
163
|
};
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { describe, expect, test } from 'bun:test';
|
|
6
6
|
import type { FileStat, HostRunner } from '../launchd/runner.ts';
|
|
7
|
-
import { SUDO } from './sudo-allowlist.ts';
|
|
7
|
+
import { CHOWN, INSTALL, SUDO } from './sudo-allowlist.ts';
|
|
8
|
+
import { fakeSudoHost } from './fake-sudo.ts';
|
|
8
9
|
import {
|
|
9
10
|
BASE64,
|
|
10
11
|
SudoRefusedError,
|
|
@@ -90,3 +91,110 @@ describe('privilegedReads', () => {
|
|
|
90
91
|
expect(checkPrivilegedReads([SECRETISH]).has(SECRETISH)).toBe(true);
|
|
91
92
|
});
|
|
92
93
|
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The write side of the same grant (the /etc/nut convergence, 2026-10-18): a file the runner
|
|
97
|
+
* reads back as root may be WRITTEN with a mode that hides it from the operator — its read-back
|
|
98
|
+
* goes through `sudo -n base64`, so it cannot strand itself. Every other refusal (setuid,
|
|
99
|
+
* group-writable, the path chain) still applies, and an UNLISTED file with the same mode keeps
|
|
100
|
+
* refusing exactly as before.
|
|
101
|
+
*/
|
|
102
|
+
describe('privilegedReads on the write path', () => {
|
|
103
|
+
const NUTISH = '/etc/nut/upsd.users';
|
|
104
|
+
const bytes = new TextEncoder().encode('new bytes\n');
|
|
105
|
+
const owner = { gid: 113, mode: 0o640, uid: 0 };
|
|
106
|
+
|
|
107
|
+
const mk = (options: { readonly listed?: boolean } = {}) =>
|
|
108
|
+
fakeSudoHost({}, new Set(), {
|
|
109
|
+
operatorGroups: [1000],
|
|
110
|
+
prefixes: ['/etc/nut'],
|
|
111
|
+
...(options.listed === false ? {} : { privilegedReads: [NUTISH] }),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('plan-time checkWrite passes for the listed 0640 root:nut file, refuses when unlisted', async () => {
|
|
115
|
+
const { runner } = mk();
|
|
116
|
+
const unlisted = mk({ listed: false });
|
|
117
|
+
if (runner.checkWrite === undefined || unlisted.runner.checkWrite === undefined) {
|
|
118
|
+
throw new Error('sshSudoRunner must expose checkWrite');
|
|
119
|
+
}
|
|
120
|
+
await runner.checkWrite(NUTISH, owner);
|
|
121
|
+
await expect(unlisted.runner.checkWrite(NUTISH, owner)).rejects.toThrow('would hide it');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('writeFileAtomic installs and chowns the listed 0640 root:nut file under the prefix', async () => {
|
|
125
|
+
const { fake, privileged, runner } = mk();
|
|
126
|
+
fake.files.set(NUTISH, {
|
|
127
|
+
bytes: new TextEncoder().encode('old'),
|
|
128
|
+
gid: 113,
|
|
129
|
+
kind: 'file',
|
|
130
|
+
mode: 0o640,
|
|
131
|
+
uid: 0,
|
|
132
|
+
});
|
|
133
|
+
await runner.writeFileAtomic(NUTISH, bytes, owner);
|
|
134
|
+
const root = privileged();
|
|
135
|
+
expect(root.some((call) => call[0] === INSTALL)).toBe(true);
|
|
136
|
+
expect(root.some((call) => call[0] === CHOWN && call[1] === ':+113')).toBe(true);
|
|
137
|
+
const after = fake.files.get(NUTISH);
|
|
138
|
+
expect(new TextDecoder().decode(after?.bytes)).toBe('new bytes\n');
|
|
139
|
+
expect(after?.mode).toBe(0o640);
|
|
140
|
+
expect(after?.uid).toBe(0);
|
|
141
|
+
expect(after?.gid).toBe(113);
|
|
142
|
+
/**
|
|
143
|
+
* ★ THE JOINED GUARANTEE (deepseek round-2 ask): the file the guard let us write in a mode
|
|
144
|
+
* that hides it from the operator reads back AS ROOT through the listed path — never
|
|
145
|
+
* EACCES, never the operator's own read. This is the stranding the guard existed to
|
|
146
|
+
* prevent, now provably absent end-to-end.
|
|
147
|
+
*/
|
|
148
|
+
const before = root.length;
|
|
149
|
+
expect(new TextDecoder().decode(await runner.readFile(NUTISH))).toBe('new bytes\n');
|
|
150
|
+
expect(privileged()[before]).toEqual([BASE64, '--', NUTISH]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('an unlisted 0640 write still refuses, and nothing runs as root', async () => {
|
|
154
|
+
const { privileged, runner } = mk({ listed: false });
|
|
155
|
+
await expect(runner.writeFileAtomic(NUTISH, bytes, owner)).rejects.toThrow('would hide it');
|
|
156
|
+
expect(privileged()).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('with the set non-empty, a sibling under the same prefix still refuses', async () => {
|
|
160
|
+
// The skip is set MEMBERSHIP, never a prefix match: with NUTISH listed, its sibling
|
|
161
|
+
// upsmon.conf — same directory, same shape, same mode — keeps the refusal
|
|
162
|
+
// (glm round-2 ask, 2026-10-18).
|
|
163
|
+
const { privileged, runner } = mk();
|
|
164
|
+
if (runner.checkWrite === undefined) throw new Error('sshSudoRunner must expose checkWrite');
|
|
165
|
+
await expect(runner.checkWrite('/etc/nut/upsmon.conf', owner)).rejects.toThrow('would hide it');
|
|
166
|
+
expect(privileged()).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('a listed file still refuses setuid and group-writable modes', async () => {
|
|
170
|
+
const { runner } = mk();
|
|
171
|
+
if (runner.checkWrite === undefined) throw new Error('sshSudoRunner must expose checkWrite');
|
|
172
|
+
await expect(runner.checkWrite(NUTISH, { gid: 0, mode: 0o4755, uid: 0 })).rejects.toThrow(
|
|
173
|
+
'setuid/setgid',
|
|
174
|
+
);
|
|
175
|
+
await expect(runner.checkWrite(NUTISH, { gid: 0, mode: 0o0660, uid: 0 })).rejects.toThrow(
|
|
176
|
+
'writable by group',
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('a listed path handed to a non-root uid is refused outright, whatever its mode', async () => {
|
|
181
|
+
// The elevated read itself refuses anything but a root-owned regular file, so a handoff —
|
|
182
|
+
// even one with operator-readable other bits (0644 uid 1001) that the plain read-back check
|
|
183
|
+
// would pass — would land and then fail EVERY read: the stranding the guard exists to
|
|
184
|
+
// prevent (deepseek round-1; the other-readable case is the glm round ask).
|
|
185
|
+
const { privileged, runner } = mk();
|
|
186
|
+
if (runner.checkWrite === undefined) throw new Error('sshSudoRunner must expose checkWrite');
|
|
187
|
+
for (const handoff of [
|
|
188
|
+
{ gid: 113, mode: 0o640, uid: 1001 },
|
|
189
|
+
{ gid: 0, mode: 0o644, uid: 1001 },
|
|
190
|
+
]) {
|
|
191
|
+
await expect(runner.checkWrite(NUTISH, handoff)).rejects.toThrow(
|
|
192
|
+
'Handing it to uid 1001 would land and then fail every read',
|
|
193
|
+
);
|
|
194
|
+
await expect(runner.writeFileAtomic(NUTISH, bytes, handoff)).rejects.toThrow(
|
|
195
|
+
'Keep it root-owned',
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
expect(privileged()).toEqual([]);
|
|
199
|
+
});
|
|
200
|
+
});
|
package/src/linux/sudo-runner.ts
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* is the "no silent sudo" failure this exists to prevent.
|
|
9
9
|
* ⛔ EVERY PRIVILEGED ARGV IS LOGGED BEFORE IT RUNS, and a file's bytes never reach argv or the
|
|
10
10
|
* log: they travel through the staged file (sudo-stage.ts) alone.
|
|
11
|
-
* ⛔ A PLAN
|
|
11
|
+
* ⛔ A PLAN ELEVATES EXACTLY ONE KIND OF CALL: the `privilegedReads` file's own read (below) —
|
|
12
|
+
* the read a plan diff needs on a declared root-owned file. Every other read, and every
|
|
13
|
+
* `checkWrite` host probe (`id -G`, `ls`, `stat`), runs through `base` — the plain
|
|
12
14
|
* `sshRunner()` — never through sudo. Nothing here probes sudo at construction.
|
|
13
15
|
* ★ PROMISES, NOT EFFECTS, ON PURPOSE — the same divergence `../launchd/runner.ts` documents
|
|
14
16
|
* (H3 in the provider-standard skill): a consumer's own runner is a plain async implementation.
|
|
@@ -120,7 +122,7 @@ export const makeSshSudoRunner = (prefixList: readonly string[], deps: SshSudoDe
|
|
|
120
122
|
|
|
121
123
|
return {
|
|
122
124
|
checkWrite: async (path, options) => {
|
|
123
|
-
await vetWrite(base, prefixes, path, options);
|
|
125
|
+
await vetWrite(base, prefixes, path, options, readable);
|
|
124
126
|
},
|
|
125
127
|
effectiveUid: () => base.effectiveUid(),
|
|
126
128
|
exec: async (argv) => {
|
|
@@ -174,7 +176,7 @@ export const makeSshSudoRunner = (prefixList: readonly string[], deps: SshSudoDe
|
|
|
174
176
|
sleep: (ms) => base.sleep(ms),
|
|
175
177
|
stat: (path) => base.stat(path),
|
|
176
178
|
writeFileAtomic: async (path, bytes, options) => {
|
|
177
|
-
const vetted = await vetWrite(base, prefixes, path, options);
|
|
179
|
+
const vetted = await vetWrite(base, prefixes, path, options, readable);
|
|
178
180
|
if (vetted === undefined) {
|
|
179
181
|
return base.writeFileAtomic(path, bytes, options).catch(outsidePrefixHint(path, prefixes));
|
|
180
182
|
}
|
package/src/linux/sudo-write.ts
CHANGED
|
@@ -73,12 +73,16 @@ export type Elevate = (
|
|
|
73
73
|
* Plan-time and write-time share this: valid mode, the path chain, mode/owner refusals and the
|
|
74
74
|
* read-back — everything a write can be refused for before anything is staged or run as root.
|
|
75
75
|
* `undefined` outside every prefix, where the caller falls back to `base` directly.
|
|
76
|
+
* ★ `privilegedReads` is the runner's exact-path elevated-read set: a file on it is read back as
|
|
77
|
+
* root (sudo-runner.ts's readFile), so the read-back refusal does not apply to it — the other
|
|
78
|
+
* checks in assertInstallable still do.
|
|
76
79
|
*/
|
|
77
80
|
export const vetWrite = async (
|
|
78
81
|
base: HostRunner,
|
|
79
82
|
prefixes: readonly string[],
|
|
80
83
|
path: string,
|
|
81
84
|
options: WriteOptions,
|
|
85
|
+
privilegedReads?: ReadonlySet<string>,
|
|
82
86
|
): Promise<{ readonly mode: string; readonly prefix: string } | undefined> => {
|
|
83
87
|
const prefix = prefixOf(path, prefixes);
|
|
84
88
|
if (prefix === undefined) return undefined;
|
|
@@ -86,7 +90,7 @@ export const vetWrite = async (
|
|
|
86
90
|
if (mode === undefined)
|
|
87
91
|
throw new SudoRefusedError(`sshSudoRunner ${path}: mode must be 0–0o7777`);
|
|
88
92
|
const groups = await operatorGroups(base);
|
|
89
|
-
await assertInstallable(base, prefix, path, options, groups);
|
|
93
|
+
await assertInstallable(base, prefix, path, options, groups, privilegedReads);
|
|
90
94
|
return { mode, prefix };
|
|
91
95
|
};
|
|
92
96
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* talosctl binary override. Fake spawner only — the named path is never executed (it only has to
|
|
3
3
|
* exist and be a trusted file; the fake answers `version --client` with the pinned version).
|
|
4
4
|
*/
|
|
5
|
-
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { chmodSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { afterAll, expect, test } from 'bun:test';
|
|
@@ -26,6 +26,8 @@ trustBoundaryForTests(dir);
|
|
|
26
26
|
const fixture = join(dir, 'talosctl');
|
|
27
27
|
writeFileSync(fixture, '#!/bin/sh\n', { mode: 0o755 });
|
|
28
28
|
chmodSync(fixture, 0o755);
|
|
29
|
+
// macOS tmpdir is /var, a symlink to /private/var. The vetted command is the real path.
|
|
30
|
+
const resolved = realpathSync(fixture);
|
|
29
31
|
afterAll(() => rmSync(dir, { force: true, recursive: true }));
|
|
30
32
|
|
|
31
33
|
const run = (binary: string | undefined, calls: FakeCall[]) =>
|
|
@@ -56,7 +58,7 @@ test('the binary option wins over HF_TALOSCTL', async () => {
|
|
|
56
58
|
await withEnv('/definitely/not/here/talosctl', async () => {
|
|
57
59
|
const calls: FakeCall[] = [];
|
|
58
60
|
await run(fixture, calls);
|
|
59
|
-
expect(calls[0]?.command).toBe(
|
|
61
|
+
expect(calls[0]?.command).toBe(resolved);
|
|
60
62
|
});
|
|
61
63
|
});
|
|
62
64
|
|
|
@@ -64,7 +66,7 @@ test('HF_TALOSCTL selects the binary when the option is omitted', async () => {
|
|
|
64
66
|
await withEnv(fixture, async () => {
|
|
65
67
|
const calls: FakeCall[] = [];
|
|
66
68
|
await run(undefined, calls);
|
|
67
|
-
expect(calls[0]?.command).toBe(
|
|
69
|
+
expect(calls[0]?.command).toBe(resolved);
|
|
68
70
|
});
|
|
69
71
|
});
|
|
70
72
|
|
|
@@ -84,7 +86,7 @@ test('the default talosctl is resolved on PATH, vetted and version-checked by ab
|
|
|
84
86
|
withPath(dir, async () => {
|
|
85
87
|
const calls: FakeCall[] = [];
|
|
86
88
|
await run(undefined, calls);
|
|
87
|
-
expect(calls.map((call) => call.command)).toEqual([
|
|
89
|
+
expect(calls.map((call) => call.command)).toEqual([resolved, resolved]);
|
|
88
90
|
}),
|
|
89
91
|
);
|
|
90
92
|
});
|