@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,12 @@
|
|
|
1
|
+
/** Bare `directory-lifecycle.ts` argv (`0`, `:0`, `0:0`, …) → the `+`-forced form sudo may run. */
|
|
2
|
+
export declare const forceNumericOwner: (spec: string) => string;
|
|
3
|
+
/**
|
|
4
|
+
* `chown`'s dispatch: a call bound to `context.temp` (its last argument equals it) is the
|
|
5
|
+
* file-ownership form above; everything else is the directory form, root-only
|
|
6
|
+
* (`sudo-allowlist-dir.ts`'s own `ROOT_OWNER`, `+`-forced the same way).
|
|
7
|
+
*/
|
|
8
|
+
export declare const chownProblem: (args: readonly string[], context: {
|
|
9
|
+
readonly prefixes: readonly string[];
|
|
10
|
+
readonly temp?: string;
|
|
11
|
+
}) => string | undefined;
|
|
12
|
+
//# sourceMappingURL=sudo-allowlist-chown.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sudo-allowlist-chown.d.ts","sourceRoot":"","sources":["../../src/linux/sudo-allowlist-chown.ts"],"names":[],"mappings":"AAuBA,mGAAmG;AACnG,eAAO,MAAM,iBAAiB,SAAU,MAAM,KAAG,MAKhD,CAAC;AA4BF;;;;GAIG;AACH,eAAO,MAAM,YAAY,SACjB,SAAS,MAAM,EAAE,WACd;IAAE,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,KACxE,MAAM,GAAG,SAKX,CAAC"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const MKDIR = "/usr/bin/mkdir";
|
|
2
|
+
export declare const CHMOD = "/usr/bin/chmod";
|
|
3
|
+
export declare const CHOWN = "/usr/bin/chown";
|
|
4
|
+
export declare const RMDIR = "/usr/bin/rmdir";
|
|
5
|
+
export declare const dirProgramProblem: (program: string, args: readonly string[], prefixes: readonly string[]) => string | undefined;
|
|
6
|
+
//# sourceMappingURL=sudo-allowlist-dir.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sudo-allowlist-dir.d.ts","sourceRoot":"","sources":["../../src/linux/sudo-allowlist-dir.ts"],"names":[],"mappings":"AAiCA,eAAO,MAAM,KAAK,mBAAmB,CAAC;AACtC,eAAO,MAAM,KAAK,mBAAmB,CAAC;AACtC,eAAO,MAAM,KAAK,mBAAmB,CAAC;AACtC,eAAO,MAAM,KAAK,mBAAmB,CAAC;AA4BtC,eAAO,MAAM,iBAAiB,YACnB,MAAM,QACT,SAAS,MAAM,EAAE,YACb,SAAS,MAAM,EAAE,KAC1B,MAAM,GAAG,SAkCX,CAAC"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { INSTALL, SUDO, SudoRefusedError, checkPrefixes, octalMode, prefixOf } from '../launchd/sudo-allowlist.ts';
|
|
2
|
+
export { INSTALL, SUDO, SudoRefusedError, checkPrefixes, octalMode, prefixOf };
|
|
3
|
+
export { CHMOD, CHOWN, MKDIR, RMDIR } from './sudo-allowlist-dir.ts';
|
|
4
|
+
/** ★ Absolute program paths, so neither sudo nor the command is looked up on the operator's PATH. */
|
|
5
|
+
export declare const MV = "/usr/bin/mv";
|
|
6
|
+
export declare const RM = "/usr/bin/rm";
|
|
7
|
+
export declare const SYSTEMCTL_ABS = "/usr/bin/systemctl";
|
|
8
|
+
/** The bare names `directory-lifecycle.ts` and `systemctl.ts` already send through `exec()`. */
|
|
9
|
+
export declare const DIRECTORY_PROGRAMS: ReadonlySet<string>;
|
|
10
|
+
/**
|
|
11
|
+
* The bare argv a provider sends → the absolute argv sudo may run. Unknown programs pass through.
|
|
12
|
+
* ★ `chown`'s owner argument is ALSO rewritten here, to the `+`-forced form `sudo-allowlist-dir.ts`
|
|
13
|
+
* and `sudo-allowlist-chown.ts` now require — `directory-lifecycle.ts` never has to know that
|
|
14
|
+
* syntax exists, and the allowlist checks exactly what will actually reach `sudo`.
|
|
15
|
+
*/
|
|
16
|
+
export declare const canonicalize: (argv: readonly string[]) => readonly string[];
|
|
17
|
+
/** The one source `install` may copy, the one temp `install`/`mv` may touch, and the prefixes. */
|
|
18
|
+
export type AllowContext = {
|
|
19
|
+
readonly prefixes: readonly string[];
|
|
20
|
+
readonly staged?: string;
|
|
21
|
+
readonly temp?: string;
|
|
22
|
+
};
|
|
23
|
+
/** `undefined` when `argv` is exactly an allowed privileged shape, else the reason it is not. */
|
|
24
|
+
export declare const privilegedProblem: (argv: readonly string[], context: AllowContext) => string | undefined;
|
|
25
|
+
/** Where one exec goes: run as the operator, run as root through the allowlist, or refused. */
|
|
26
|
+
export type Route = {
|
|
27
|
+
readonly as: 'operator';
|
|
28
|
+
} | {
|
|
29
|
+
readonly as: 'root';
|
|
30
|
+
} | {
|
|
31
|
+
readonly refuse: string;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* ★ ROUTING NEVER TOUCHES THE HOST. mkdir/chmod/chown/rmdir go to root only under a declared
|
|
35
|
+
* prefix — outside one they run as the operator, who may well own that path (H3/rule 7).
|
|
36
|
+
* systemctl's write verbs always ATTEMPT root; the FragmentPath check that can still refuse one
|
|
37
|
+
* needs a host read, so it runs in the runner's `before` hook, not here.
|
|
38
|
+
*/
|
|
39
|
+
export declare const routeExec: (argv: readonly string[], prefixes: readonly string[]) => Route;
|
|
40
|
+
//# sourceMappingURL=sudo-allowlist.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sudo-allowlist.d.ts","sourceRoot":"","sources":["../../src/linux/sudo-allowlist.ts"],"names":[],"mappings":"AAoBA,OAAO,EACL,OAAO,EACP,IAAI,EACJ,gBAAgB,EAChB,aAAa,EACb,SAAS,EACT,QAAQ,EACT,MAAM,8BAA8B,CAAC;AAItC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,gBAAgB,EAAE,aAAa,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC;AAC/E,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,yBAAyB,CAAC;AAErE,qGAAqG;AACrG,eAAO,MAAM,EAAE,gBAAgB,CAAC;AAChC,eAAO,MAAM,EAAE,gBAAgB,CAAC;AAChC,eAAO,MAAM,aAAa,uBAAuB,CAAC;AAElD,gGAAgG;AAChG,eAAO,MAAM,kBAAkB,EAAE,WAAW,CAAC,MAAM,CAKjD,CAAC;AASH;;;;;GAKG;AACH,eAAO,MAAM,YAAY,SAAU,SAAS,MAAM,EAAE,KAAG,SAAS,MAAM,EAQrE,CAAC;AAIF,kGAAkG;AAClG,MAAM,MAAM,YAAY,GAAG;IACzB,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AA2FF,iGAAiG;AACjG,eAAO,MAAM,iBAAiB,SACtB,SAAS,MAAM,EAAE,WACd,YAAY,KACpB,MAAM,GAAG,SAWX,CAAC;AAEF,+FAA+F;AAC/F,MAAM,MAAM,KAAK,GACb;IAAE,QAAQ,CAAC,EAAE,EAAE,UAAU,CAAA;CAAE,GAC3B;IAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;CAAE,GACvB;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEhC;;;;;GAKG;AACH,eAAO,MAAM,SAAS,SAAU,SAAS,MAAM,EAAE,YAAY,SAAS,MAAM,EAAE,KAAG,KAyBhF,CAAC"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sshSudoRunner.exec()`'s root branch: the host reads that decide whether a routed-to-root
|
|
3
|
+
* directory or systemctl call may actually elevate. Split out of sudo-runner.ts, which keeps only
|
|
4
|
+
* the wiring. sudo-allowlist.ts's `routeExec` already decided root vs operator vs refuse from the
|
|
5
|
+
* argv alone; this is the part that needs the host.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ THE FragmentPath CHECK IS WHAT MAKES `pveproxy.service` UNREACHABLE. A vendor unit's file
|
|
8
|
+
* lives under `/usr/lib/systemd/system` or `/lib/systemd/system` — never a prefix this runner
|
|
9
|
+
* would declare — so `enable`/`start`/… on it is refused before sudo runs. A unit with NO file
|
|
10
|
+
* at all (FragmentPath empty) is let through for `stop`/`disable` only, because
|
|
11
|
+
* `unit-lifecycle.ts` `deleteUnit` calls exactly those two on a unit whose file this same
|
|
12
|
+
* delete already removed a moment before — refusing that would make a second, idempotent
|
|
13
|
+
* delete pass fail.
|
|
14
|
+
* 🔴 MEASURED (adversarial review, round 2, 2026-09-23): two gaps in this check, both closed here.
|
|
15
|
+
* (1) A MASKED unit (`systemctl mask`, a symlink to `/dev/null`) also reads `FragmentPath=`
|
|
16
|
+
* empty, so `enable`/`start`/`restart` on a masked `pveproxy.service` would have elevated —
|
|
17
|
+
* `systemctl` itself would then refuse the write, but nothing here should even try; masking is
|
|
18
|
+
* someone's deliberate decision (`unit-preflight.ts`'s `assertUsable` already refuses it at the
|
|
19
|
+
* PROVIDER layer, and this is the same rule enforced again at the lowest level, in case a
|
|
20
|
+
* caller reaches `exec()` directly). (2) The empty-FragmentPath exemption applied to EVERY
|
|
21
|
+
* write verb, not just the two `deleteUnit` actually needs — so `enable`/`start`/`restart` on a
|
|
22
|
+
* kernel-generated pseudo-unit with no file at all (`init.scope`, a `session-N.scope`; `.scope`
|
|
23
|
+
* is a valid type in `unit-form.ts`'s `TYPES` set) would also have elevated. Grepped: no code
|
|
24
|
+
* in this package ever calls `enable`/`start`/`restart` on a unit it has not itself just
|
|
25
|
+
* written a file for, so narrowing the exemption to `stop`/`disable` costs nothing legitimate.
|
|
26
|
+
*/
|
|
27
|
+
import type { ExecResult, HostRunner } from '../launchd/runner.ts';
|
|
28
|
+
import type { Elevate } from './sudo-write.ts';
|
|
29
|
+
/** `argv` has already been routed to root; canonicalise it and run the read this shape needs. */
|
|
30
|
+
export declare const elevatedExec: (base: HostRunner, elevate: Elevate, prefixes: readonly string[], argv: readonly string[]) => Promise<ExecResult>;
|
|
31
|
+
//# sourceMappingURL=sudo-exec.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sudo-exec.d.ts","sourceRoot":"","sources":["../../src/linux/sudo-exec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAGnE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAuC/C,iGAAiG;AACjG,eAAO,MAAM,YAAY,SACjB,UAAU,WACP,OAAO,YACN,SAAS,MAAM,EAAE,QACrB,SAAS,MAAM,EAAE,KACtB,OAAO,CAAC,UAAU,CA2CpB,CAAC"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The checks sudo-runner.ts makes against the host, as the operator, before it runs a file or
|
|
3
|
+
* directory operation as root. sudo-allowlist.ts checks the argv; this checks what the paths in
|
|
4
|
+
* it actually are — the Linux twin of `../launchd/sudo-guard.ts`, rebuilt on `sudo-listing.ts`'s
|
|
5
|
+
* ONE `ls -ldn` call instead of an lstat per directory, because ssh pays a handshake per call
|
|
6
|
+
* (ssh-runner.ts: no multiplexing unless the caller opts in) where a local syscall pays nothing.
|
|
7
|
+
*
|
|
8
|
+
* ⛔ EVERY DIRECTORY FROM `/` DOWN TO THE FILE MUST BE ROOT'S ALONE, for the same reason the Mac
|
|
9
|
+
* guard insists on it: a directory anyone else may write lets them swap what lies under it — a
|
|
10
|
+
* symlink where the file was — between this read and the privileged call. A root-owned symlink
|
|
11
|
+
* ABOVE the prefix (Debian's merged-`/usr`, `/lib` -> `usr/lib`) is the system's own and is
|
|
12
|
+
* followed, exactly as `aboveDirs`/`betweenDirs` from the Mac guard already encode; a symlink AT
|
|
13
|
+
* or BELOW the prefix is refused outright — a write should never chase one there.
|
|
14
|
+
* ⛔ THE ACL SIGNAL IS THE `+` FLAG ALONE (sudo-listing.ts header): `ls`, not `getfacl`, which is
|
|
15
|
+
* absent on the estate's Debian hosts. A directory that carries one is refused whatever it
|
|
16
|
+
* grants — the guard cannot tell a read-only entry from a write one, so it fails closed.
|
|
17
|
+
*/
|
|
18
|
+
import type { FileStat, HostRunner, WriteOptions } from '../launchd/runner.ts';
|
|
19
|
+
import { aboveDirs, assertReadableBack, betweenDirs, canRead, modeProblem, operatorGroups } from '../launchd/sudo-guard.ts';
|
|
20
|
+
export { aboveDirs, assertReadableBack, betweenDirs, canRead, modeProblem, operatorGroups };
|
|
21
|
+
/** What must be at `path` itself, for the operation about to run there. */
|
|
22
|
+
export type ChainExpect = 'file' | 'file-or-absent' | 'directory' | 'absent';
|
|
23
|
+
/**
|
|
24
|
+
* Refuse a prefix that is not root-only, a symlink or wrong kind between the prefix and `path`,
|
|
25
|
+
* an ACL anywhere in the chain, or a target that is not what `expect` says. ONE `ls -ldn` for the
|
|
26
|
+
* directory chain, plus one plain `stat` for the target (see `targetProblem`'s note on why they
|
|
27
|
+
* are separate calls). ⚠️ Reads only, as the operator — so `checkWrite` runs this at plan time too.
|
|
28
|
+
*/
|
|
29
|
+
export declare const assertGuardedChain: (base: HostRunner, prefix: string, path: string, expect: ChainExpect) => Promise<FileStat | undefined>;
|
|
30
|
+
/**
|
|
31
|
+
* The mode/owner and read-back checks for a file write under `prefix`, plus the path chain.
|
|
32
|
+
* ★ Reads only, as the operator — the same checks run at plan time (`checkWrite`) and before the
|
|
33
|
+
* install/mv pair. `expect` is `'file-or-absent'`: a write may create or replace.
|
|
34
|
+
*/
|
|
35
|
+
export declare const assertInstallable: (base: HostRunner, prefix: string, path: string, options: WriteOptions, groups: readonly number[] | undefined) => Promise<void>;
|
|
36
|
+
//# sourceMappingURL=sudo-guard.d.ts.map
|
|
@@ -0,0 +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;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,SACtB,UAAU,UACR,MAAM,QACR,MAAM,WACH,YAAY,UACb,SAAS,MAAM,EAAE,GAAG,SAAS,KACpC,OAAO,CAAC,IAAI,CAWd,CAAC"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sshSudoRunner(): the Linux twin of `../launchd/sudo-runner.ts` — the same idea (the deploy runs
|
|
3
|
+
* as the operator, and only a fixed allowlist of calls goes through `sudo -n`), rebuilt for a
|
|
4
|
+
* non-root ssh user with passwordless sudo instead of a local macOS session. See
|
|
5
|
+
* docs/linux-sudo.md for the full argv table and what is measured versus reasoned.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ `sudo -n`, NEVER A PROMPT — same rule, same reason: a deploy that stops to ask for a password
|
|
8
|
+
* is the "no silent sudo" failure this exists to prevent.
|
|
9
|
+
* ⛔ EVERY PRIVILEGED ARGV IS LOGGED BEFORE IT RUNS, and a file's bytes never reach argv or the
|
|
10
|
+
* log: they travel through the staged file (sudo-stage.ts) alone.
|
|
11
|
+
* ⛔ A PLAN NEVER ELEVATES. `checkWrite` and every read run through `base` — the plain
|
|
12
|
+
* `sshRunner()` — never through sudo. Nothing here probes sudo at construction.
|
|
13
|
+
* ★ PROMISES, NOT EFFECTS, ON PURPOSE — the same divergence `../launchd/runner.ts` documents
|
|
14
|
+
* (H3 in the provider-standard skill): a consumer's own runner is a plain async implementation.
|
|
15
|
+
*/
|
|
16
|
+
import type { HostRunner } from '../launchd/runner.ts';
|
|
17
|
+
import { type SshRunnerOptions } from './ssh-runner.ts';
|
|
18
|
+
import { SudoRefusedError } from './sudo-allowlist.ts';
|
|
19
|
+
import { type Staged } from './sudo-stage.ts';
|
|
20
|
+
export { SudoRefusedError };
|
|
21
|
+
export type SshSudoRunnerOptions = SshRunnerOptions & {
|
|
22
|
+
/** Directories root may write under. Required; absolute, normalised, never `/`. */
|
|
23
|
+
readonly prefixes: readonly string[];
|
|
24
|
+
/** One line per privileged argv, before it runs. @default a line on stderr */
|
|
25
|
+
readonly log?: (line: string) => void;
|
|
26
|
+
};
|
|
27
|
+
/** What a sudo runner is built from. ★ Split out so the tests swap the host, never the logic. */
|
|
28
|
+
export type SshSudoDeps = {
|
|
29
|
+
/** Runs everything, including `sudo` itself. `await sshRunner(options)` for the real one. */
|
|
30
|
+
readonly base: HostRunner;
|
|
31
|
+
readonly stage: (bytes: Uint8Array) => Promise<Staged>;
|
|
32
|
+
readonly log: (line: string) => void;
|
|
33
|
+
};
|
|
34
|
+
export declare const makeSshSudoRunner: (prefixList: readonly string[], deps: SshSudoDeps) => HostRunner;
|
|
35
|
+
/** The real sudo runner: `sshRunner()` for everything, `/usr/bin/sudo -n` for the allowlist. */
|
|
36
|
+
export declare const sshSudoRunner: (options: SshSudoRunnerOptions) => Promise<HostRunner>;
|
|
37
|
+
//# sourceMappingURL=sudo-runner.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sudo-runner.d.ts","sourceRoot":"","sources":["../../src/linux/sudo-runner.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;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;CACvC,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;CACtC,CAAC;AAeF,eAAO,MAAM,iBAAiB,eAAgB,SAAS,MAAM,EAAE,QAAQ,WAAW,KAAG,UA2DpF,CAAC;AAsBF,gGAAgG;AAChG,eAAO,MAAM,aAAa,YAAmB,oBAAoB,KAAG,OAAO,CAAC,UAAU,CAYrF,CAAC"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where sudo-runner.ts puts a file's bytes before `install` copies them into a fresh temp file
|
|
3
|
+
* that `mv` then renames into place. The Linux twin of `../launchd/sudo-stage.ts` — rewritten,
|
|
4
|
+
* not reused, because staging happens ON THE REMOTE HOST through the base runner's own calls,
|
|
5
|
+
* never through `node:fs` against this process's local disk.
|
|
6
|
+
*
|
|
7
|
+
* ★ WHY STAGE AT ALL. The operator cannot write inside a root-owned directory, and the bytes must
|
|
8
|
+
* not travel through argv (they would reach the log and `ps`). So they are written, as the
|
|
9
|
+
* operator, to a private file on the SAME host, and `install` copies that one file.
|
|
10
|
+
* ⛔ A 0700 DIRECTORY, A 0600 FILE: between the write and the install, nobody but the operator and
|
|
11
|
+
* root can read the bytes or swap the file. `mktemp -d` creates its directory 0700 by
|
|
12
|
+
* construction (GNU coreutils); the payload's own mode is asked for explicitly.
|
|
13
|
+
*/
|
|
14
|
+
import type { HostRunner } from '../launchd/runner.ts';
|
|
15
|
+
/** A file staged for `install`, and how to remove it again. */
|
|
16
|
+
export type Staged = {
|
|
17
|
+
readonly path: string;
|
|
18
|
+
readonly dispose: () => Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
/** `stage` in `SshSudoDeps` — closes over the base runner so every call writes to the same host. */
|
|
21
|
+
export declare const stageRemote: (base: HostRunner) => (bytes: Uint8Array) => Promise<Staged>;
|
|
22
|
+
//# sourceMappingURL=sudo-stage.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sudo-stage.d.ts","sourceRoot":"","sources":["../../src/linux/sudo-stage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,+DAA+D;AAC/D,MAAM,MAAM,MAAM,GAAG;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,CAAC;AAItF,oGAAoG;AACpG,eAAO,MAAM,WAAW,SACf,UAAU,aACH,UAAU,KAAG,OAAO,CAAC,MAAM,CA0BxC,CAAC"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ExecResult, HostRunner, WriteOptions } from '../launchd/runner.ts';
|
|
2
|
+
import type { Staged } from './sudo-stage.ts';
|
|
3
|
+
/** `<dir>/.<basename>.hf-<12 hex>.tmp`, in `dest`'s own directory — never anywhere else. */
|
|
4
|
+
export declare const deriveTemp: (dest: string) => string;
|
|
5
|
+
/** A privileged call: allowlisted, logged, run. Shared with sudo-runner.ts's own exec() routing. */
|
|
6
|
+
export type Elevate = (argv: readonly string[], context?: {
|
|
7
|
+
readonly staged?: string;
|
|
8
|
+
readonly temp?: string;
|
|
9
|
+
}, before?: () => Promise<unknown>) => Promise<ExecResult>;
|
|
10
|
+
/**
|
|
11
|
+
* Plan-time and write-time share this: valid mode, the path chain, mode/owner refusals and the
|
|
12
|
+
* read-back — everything a write can be refused for before anything is staged or run as root.
|
|
13
|
+
* `undefined` outside every prefix, where the caller falls back to `base` directly.
|
|
14
|
+
*/
|
|
15
|
+
export declare const vetWrite: (base: HostRunner, prefixes: readonly string[], path: string, options: WriteOptions) => Promise<{
|
|
16
|
+
readonly mode: string;
|
|
17
|
+
readonly prefix: string;
|
|
18
|
+
} | undefined>;
|
|
19
|
+
/**
|
|
20
|
+
* Stage, `install` into a fresh temp file beside `path`, `chown` it if a non-root owner was
|
|
21
|
+
* declared, `mv` it into place. Cleans up the temp on a failed `mv`, and always disposes the
|
|
22
|
+
* stage — whether or not the write succeeded.
|
|
23
|
+
*/
|
|
24
|
+
export declare const writeUnderPrefix: (base: HostRunner, elevate: Elevate, stage: (bytes: Uint8Array) => Promise<Staged>, path: string, bytes: Uint8Array, options: WriteOptions, mode: string) => Promise<void>;
|
|
25
|
+
//# sourceMappingURL=sudo-write.d.ts.map
|
|
@@ -0,0 +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;;;;GAIG;AACH,eAAO,MAAM,QAAQ,SACb,UAAU,YACN,SAAS,MAAM,EAAE,QACrB,MAAM,WACH,YAAY,KACpB,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"}
|
|
@@ -62,10 +62,53 @@ export declare const UNIT_WRITE: {
|
|
|
62
62
|
export declare const digestOf: (text: string) => string;
|
|
63
63
|
/** The digests of everything outside the unit file that should restart it, as one value. */
|
|
64
64
|
export declare const configDigest: (restartOn: readonly string[] | undefined) => string;
|
|
65
|
+
/**
|
|
66
|
+
* Whether `activeState`/`subState` count as "the unit is running" against a declared `started` —
|
|
67
|
+
* the ONE place this decision is made, so `diffUnit` (unit-lifecycle.ts) and `settle`
|
|
68
|
+
* (unit-settle.ts) can never disagree about it.
|
|
69
|
+
*
|
|
70
|
+
* ⛔ WHY THIS EXISTS: a `Type=oneshot` service driven by a `.timer` (no `[Install]`, declared
|
|
71
|
+
* `started: false, enabled: false` — its timer starts it, not this resource) reports
|
|
72
|
+
* `ActiveState=activating` for the run's whole duration, not an instant. Written for the PVE
|
|
73
|
+
* node-check unit that first hit this: ~1.2s, every 60s. A plan taken mid-run must not read that
|
|
74
|
+
* run as drift, and a deploy must not `systemctl stop` a check that is already in flight.
|
|
75
|
+
* ★ systemd.service(5) §`Type=`, `oneshot` (man7.org mirror, read 2026-09-23): with no
|
|
76
|
+
* `RemainAfterExit=`, the service manager holds the unit `activating` for as long as
|
|
77
|
+
* `ExecStart=` runs, then moves it straight to `dead`/`inactive` (or `failed`) — it never passes
|
|
78
|
+
* through `active`. So for `started: false`, `activating` is exactly a run in progress, and
|
|
79
|
+
* `active` is the one state a oneshot never reaches on its own: that is the drift, and the only
|
|
80
|
+
* thing settle stops.
|
|
81
|
+
* ⛔ CRASH-LOOP GUARD, added on adversarial review: `ActiveState=activating` is not only "starting
|
|
82
|
+
* for the first time" — a service with `Restart=` waiting out its `RestartSec=` backoff between
|
|
83
|
+
* crash attempts is ALSO `activating`, with `SubState=auto-restart` (or `auto-restart-queued`).
|
|
84
|
+
* A unit declared `started: false` that is crash-looping sits there for nearly all of its
|
|
85
|
+
* wall-clock time. Without this guard the oneshot exemption above would read that loop as
|
|
86
|
+
* converged forever, and a deploy would never again issue the `stop` that ends it — silently
|
|
87
|
+
* defeating `started: false` on exactly the unit that most needs it enforced. So only a genuine
|
|
88
|
+
* start (`SubState` anything else — `start`, `start-pre`, `start-post`, …) gets the exemption;
|
|
89
|
+
* an auto-restart substate counts as running regardless of `started`, same as `active` does.
|
|
90
|
+
* ★ SOURCE, not measured against a live host: systemd's own `service.c`
|
|
91
|
+
* `state_translation_table` (github.com/systemd/systemd, read 2026-09-23) maps both
|
|
92
|
+
* `SERVICE_AUTO_RESTART` and `SERVICE_AUTO_RESTART_QUEUED` to `UNIT_ACTIVATING`, and
|
|
93
|
+
* `unit-def.c`'s `service_state_table` names their `SubState` strings `"auto-restart"` and
|
|
94
|
+
* `"auto-restart-queued"`. ⚠️ REASONED NOT MEASURED: no unit was actually crash-looped and read
|
|
95
|
+
* back with `systemctl show` for this change — the systemd source is the citation, unlike
|
|
96
|
+
* systemctl.ts's own header, which is a live-host trace.
|
|
97
|
+
* ⚠️ THE TRADE-OFF THIS ACCEPTS: a `Type=notify` (or other long-running type) unit stuck in a
|
|
98
|
+
* genuine `activating`/`start` with `started: false` — never reaching `active` and never
|
|
99
|
+
* crash-looping either — reads as converged, not drift, until it reaches `active`. A unit hung
|
|
100
|
+
* like that is exactly the case a human should look at, not one this predicate auto-corrects by
|
|
101
|
+
* stopping it out from under whatever else depends on it.
|
|
102
|
+
* ★ `started: true` (the default, when omitted) is unchanged: `active` or `activating` (any
|
|
103
|
+
* `subState`) both count as running, so a start already in flight is never started twice.
|
|
104
|
+
*/
|
|
105
|
+
export declare const isUnitRunning: (activeState: string, subState: string | undefined, started: boolean) => boolean;
|
|
65
106
|
/** Sections → an INI unit file. ★ Keys and values pass through untouched; see the file header. */
|
|
66
107
|
export declare const renderUnit: (sections: readonly UnitSection[]) => string;
|
|
67
108
|
export declare const unitText: (props: SystemdUnitProps) => string;
|
|
68
109
|
export declare const unitPathFor: (props: SystemdUnitProps) => string;
|
|
110
|
+
/** ★ Exported so sudo-allowlist.ts validates a systemctl unit operand against the same pattern. */
|
|
111
|
+
export declare const NAME: RegExp;
|
|
69
112
|
/**
|
|
70
113
|
* Every refusal at once, so one plan shows the whole list.
|
|
71
114
|
* ⛔ THE TWO STRUCTURAL RULES, both proven by systemd's own output rather than assumed:
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"unit-form.d.ts","sourceRoot":"","sources":["../../src/linux/unit-form.ts"],"names":[],"mappings":"AAiBA,MAAM,MAAM,WAAW,GAAG;IACxB,yFAAyF;IACzF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,EAAE,CAAC;CACpE,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,qFAAqF;IACrF,IAAI,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,iGAAiG;IACjG,QAAQ,CAAC,EAAE,SAAS,WAAW,EAAE,CAAC;IAClC,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oDAAoD;IACpD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,gDAAgD;IAChD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC/B,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,oDAAoD;IACpD,UAAU,EAAE,MAAM,CAAC;IACnB,4DAA4D;IAC5D,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF,iGAAiG;AACjG,eAAO,MAAM,sBAAsB,wBAAwB,CAAC;AAE5D;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,YAClC,qBAAqB,EACrB,qBAAqB,EACrB,+BAA+B,EAC/B,yBAAyB,EACzB,qBAAqB,CACb,CAAC;AAEX,mGAAmG;AACnG,eAAO,MAAM,UAAU;aAAK,GAAG,EAAE,CAAC;aAAE,IAAI,EAAE,GAAK;aAAE,GAAG,EAAE,CAAC;CAAW,CAAC;AAEnE,eAAO,MAAM,QAAQ,SAAU,MAAM,KAAG,MAAmD,CAAC;AAE5F,4FAA4F;AAC5F,eAAO,MAAM,YAAY,cAAe,SAAS,MAAM,EAAE,GAAG,SAAS,KAAG,MAChC,CAAC;AAEzC,kGAAkG;AAClG,eAAO,MAAM,UAAU,aAAc,SAAS,WAAW,EAAE,KAAG,MAKzC,CAAC;AAEtB,eAAO,MAAM,QAAQ,UAAW,gBAAgB,KAAG,MACA,CAAC;AAEpD,eAAO,MAAM,WAAW,UAAW,gBAAgB,KAAG,MACQ,CAAC;
|
|
1
|
+
{"version":3,"file":"unit-form.d.ts","sourceRoot":"","sources":["../../src/linux/unit-form.ts"],"names":[],"mappings":"AAiBA,MAAM,MAAM,WAAW,GAAG;IACxB,yFAAyF;IACzF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,EAAE,CAAC;CACpE,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,qFAAqF;IACrF,IAAI,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,iGAAiG;IACjG,QAAQ,CAAC,EAAE,SAAS,WAAW,EAAE,CAAC;IAClC,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oDAAoD;IACpD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,gDAAgD;IAChD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC/B,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,oDAAoD;IACpD,UAAU,EAAE,MAAM,CAAC;IACnB,4DAA4D;IAC5D,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF,iGAAiG;AACjG,eAAO,MAAM,sBAAsB,wBAAwB,CAAC;AAE5D;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,YAClC,qBAAqB,EACrB,qBAAqB,EACrB,+BAA+B,EAC/B,yBAAyB,EACzB,qBAAqB,CACb,CAAC;AAEX,mGAAmG;AACnG,eAAO,MAAM,UAAU;aAAK,GAAG,EAAE,CAAC;aAAE,IAAI,EAAE,GAAK;aAAE,GAAG,EAAE,CAAC;CAAW,CAAC;AAEnE,eAAO,MAAM,QAAQ,SAAU,MAAM,KAAG,MAAmD,CAAC;AAE5F,4FAA4F;AAC5F,eAAO,MAAM,YAAY,cAAe,SAAS,MAAM,EAAE,GAAG,SAAS,KAAG,MAChC,CAAC;AAEzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,eAAO,MAAM,aAAa,gBACX,MAAM,YACT,MAAM,GAAG,SAAS,WACnB,OAAO,KACf,OAKF,CAAC;AAEF,kGAAkG;AAClG,eAAO,MAAM,UAAU,aAAc,SAAS,WAAW,EAAE,KAAG,MAKzC,CAAC;AAEtB,eAAO,MAAM,QAAQ,UAAW,gBAAgB,KAAG,MACA,CAAC;AAEpD,eAAO,MAAM,WAAW,UAAW,gBAAgB,KAAG,MACQ,CAAC;AAE/D,mGAAmG;AACnG,eAAO,MAAM,IAAI,QAA6C,CAAC;AAkB/D;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,UAAW,gBAAgB,WAAW,MAAM,KAAG,MAAM,EAmC7E,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"unit-lifecycle.d.ts","sourceRoot":"","sources":["../../src/linux/unit-lifecycle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,OAAO,EACL,KAAK,qBAAqB,EAC1B,KAAK,gBAAgB,
|
|
1
|
+
{"version":3,"file":"unit-lifecycle.d.ts","sourceRoot":"","sources":["../../src/linux/unit-lifecycle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,OAAO,EACL,KAAK,qBAAqB,EAC1B,KAAK,gBAAgB,EAOtB,MAAM,gBAAgB,CAAC;AAOxB,oGAAoG;AACpG,eAAO,MAAM,QAAQ,WACX,UAAU,SACX,gBAAgB,KACtB,OAAO,CAAC,qBAAqB,GAAG,SAAS,CAK3C,CAAC;AAEF,eAAO,MAAM,QAAQ,WACX,UAAU,QACZ,gBAAgB,UACd,qBAAqB,WACpB,MAAM,KACd,OAAO,CAAC,IAAI,CAkCd,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,aAAa,WAChB,UAAU,SACX,gBAAgB,UACf,qBAAqB,GAAG,SAAS,4BAEhC,MAAM,KACd,OAAO,CAAC,qBAAqB,CAqD/B,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,UAAU,WACb,UAAU,UACV,qBAAqB,KAC5B,OAAO,CAAC,IAAI,CAed,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"unit-settle.d.ts","sourceRoot":"","sources":["../../src/linux/unit-settle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AACvD,OAAO,EACL,KAAK,qBAAqB,EAC1B,KAAK,gBAAgB,
|
|
1
|
+
{"version":3,"file":"unit-settle.d.ts","sourceRoot":"","sources":["../../src/linux/unit-settle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AACvD,OAAO,EACL,KAAK,qBAAqB,EAC1B,KAAK,gBAAgB,EAItB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,KAAK,UAAU,EAQhB,MAAM,gBAAgB,CAAC;AAExB,eAAO,MAAM,MAAM,SAAU,MAAM,WAAW,MAAM,KAAG,KACR,CAAC;AAEhD,eAAO,MAAM,YAAY,UAChB,gBAAgB,cACX,MAAM,UACV,UAAU,KACjB,qBAUD,CAAC;AAEH,MAAM,MAAM,MAAM,GAAG;IACnB,QAAQ,CAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAAC;IACxC,mFAAmF;IACnF,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;CAC7B,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,MAAM,WACT,UAAU,SACX,gBAAgB,QACjB,MAAM,KACX,OAAO,CAAC,qBAAqB,CA8C/B,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fake-litellm.d.ts","sourceRoot":"","sources":["../../src/litellm/fake-litellm.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,6BAA6B,CAAC;AAE9E,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,SAAS,0BAA0B,EAAE,CAAC;IAC3D,QAAQ,CAAC,QAAQ,EAAE,MAAM,SAAS,WAAW,EAAE,CAAC;IAChD,QAAQ,CAAC,IAAI,EAAE,MAAM,IAAI,CAAC;CAC3B;AAKD,eAAO,MAAM,gBAAgB,aAAc;IACzC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,0BAA0B,EAAE,CAAC;IACtD,qGAAqG;IACrG,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO,CAAC;CACjC,KAAG,
|
|
1
|
+
{"version":3,"file":"fake-litellm.d.ts","sourceRoot":"","sources":["../../src/litellm/fake-litellm.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,6BAA6B,CAAC;AAE9E,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,SAAS,0BAA0B,EAAE,CAAC;IAC3D,QAAQ,CAAC,QAAQ,EAAE,MAAM,SAAS,WAAW,EAAE,CAAC;IAChD,QAAQ,CAAC,IAAI,EAAE,MAAM,IAAI,CAAC;CAC3B;AAKD,eAAO,MAAM,gBAAgB,aAAc;IACzC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,0BAA0B,EAAE,CAAC;IACtD,qGAAqG;IACrG,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO,CAAC;CACjC,KAAG,WA2EH,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fake-pve-lxc.d.ts","sourceRoot":"","sources":["../../src/proxmox/fake-pve-lxc.ts"],"names":[],"mappings":"AAkBA,MAAM,MAAM,IAAI,GAAG;IACjB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;CAChC,CAAC;AAEF,MAAM,MAAM,OAAO,GAAG;IACpB,6FAA6F;IAC7F,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACtD,mFAAmF;IACnF,QAAQ,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACzE,4FAA4F;IAC5F,QAAQ,CAAC,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IACtE;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IACtF,6EAA6E;IAC7E,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC7B,yFAAyF;IACzF,QAAQ,CAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,OAAO,CAAA;KAAE,CAAC;IACjD,QAAQ,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,OAAO,KAAK,CAAC;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,IAAI,CAAC;CAC3B,CAAC;AAEF,+FAA+F;AAC/F,eAAO,MAAM,WAAW,eAAe,CAAC;AACxC,eAAO,MAAM,QAAQ,yBAAyB,CAAC;AAwC/C,eAAO,MAAM,OAAO,QAAO,
|
|
1
|
+
{"version":3,"file":"fake-pve-lxc.d.ts","sourceRoot":"","sources":["../../src/proxmox/fake-pve-lxc.ts"],"names":[],"mappings":"AAkBA,MAAM,MAAM,IAAI,GAAG;IACjB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;CAChC,CAAC;AAEF,MAAM,MAAM,OAAO,GAAG;IACpB,6FAA6F;IAC7F,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACtD,mFAAmF;IACnF,QAAQ,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACzE,4FAA4F;IAC5F,QAAQ,CAAC,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IACtE;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IACtF,6EAA6E;IAC7E,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC7B,yFAAyF;IACzF,QAAQ,CAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,OAAO,CAAA;KAAE,CAAC;IACjD,QAAQ,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,OAAO,KAAK,CAAC;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,IAAI,CAAC;CAC3B,CAAC;AAEF,+FAA+F;AAC/F,eAAO,MAAM,WAAW,eAAe,CAAC;AACxC,eAAO,MAAM,QAAQ,yBAAyB,CAAC;AAwC/C,eAAO,MAAM,OAAO,QAAO,OAoI1B,CAAC;AAEF,8FAA8F;AAC9F,eAAO,MAAM,QAAQ,SAAU,OAAO,aAG0C,CAAC"}
|
package/docs/launchd-sudo.md
CHANGED
|
@@ -79,9 +79,18 @@ sudo.
|
|
|
79
79
|
refuse. Measured 2026-09-21: `/Library/LaunchDaemons`, `/private/etc`, `/opt` and `/usr/local`,
|
|
80
80
|
and everything above them, carry none.
|
|
81
81
|
- **A root-owned file that would be group- or world-writable, setuid or setgid** (`mode & 0o6022`;
|
|
82
|
-
an omitted owner
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
root-owned means an omitted owner, uid `0`, **or an omitted or `0` gid** — any of the four keeps
|
|
83
|
+
every check active). ⛔ Anyone in that class could rewrite a file root installed (a daemon's
|
|
84
|
+
config, a script it runs), and a setuid root file runs as root for whoever executes it. 🔴
|
|
85
|
+
MEASURED (adversarial review, 2026-09-23, two rounds): round 1 found checking only `uid` let
|
|
86
|
+
`{uid: 501, gid: 0, mode: 0o2775}` — setgid to root's own group, group-writable, merely OWNED by
|
|
87
|
+
uid 501 — through untouched. Round 2 found the round-1 fix still missed an OMITTED `gid`: on the
|
|
88
|
+
Linux sibling runner, GNU `install` run as root via `sudo -n` defaults a missing `-g` to root's
|
|
89
|
+
own group whenever the destination directory is not itself setgid (measured true of every prefix
|
|
90
|
+
this codebase declares), so `{uid: 501, mode: 0o2775}` — `group` simply never declared — reaches
|
|
91
|
+
the identical escalation by omission; this check is shared code, so it is fixed the same way on
|
|
92
|
+
both platforms rather than relied on to differ safely by OS default. A file handed to another
|
|
93
|
+
owner is theirs to change only when BOTH the uid and the gid are explicitly, genuinely theirs.
|
|
85
94
|
- A path outside every prefix that needs root: another user as the owner, say.
|
|
86
95
|
- A symlink or missing directory between the prefix and the file, or anything but a regular file
|
|
87
96
|
at the path. ⚠️ `install src <directory>` copies _into_ the directory, and a symlink to one does
|
package/docs/linux-host.md
CHANGED
|
@@ -30,9 +30,9 @@ Layer.mergeAll(linuxProviders(runner) /* the stack's other providers */);
|
|
|
30
30
|
"nothing is there". This is the rule that stops a dropped link reading as a
|
|
31
31
|
deleted config.
|
|
32
32
|
- ⛔ **No silent sudo.** `privileged` is `false` and nothing here calls `sudo`.
|
|
33
|
-
A root-owned path needs a destination whose ssh **user is root**, or
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
A root-owned path needs a destination whose ssh **user is root**, or
|
|
34
|
+
`sshSudoRunner()` — the Linux twin of `sudoRunner()`, with its own argv
|
|
35
|
+
allowlist: [linux-sudo.md](./linux-sudo.md).
|
|
36
36
|
- The probe at construction (`uname -s`, `id -u`) refuses anything that is not
|
|
37
37
|
Linux: the scripts use GNU/BusyBox spellings (`stat -c`, `base64`, `mv -f`).
|
|
38
38
|
|
|
@@ -157,6 +157,8 @@ unit reads converged — the same limit the Mac side records.
|
|
|
157
157
|
- A failed start on a **create** removes the file this deploy wrote and reloads
|
|
158
158
|
again, so the next deploy starts clean; on an **update** the file stays and
|
|
159
159
|
state keeps the previous digest, so the next deploy retries.
|
|
160
|
+
- For `started: false`, only `ActiveState=active` is drift and gets stopped — `activating` is a
|
|
161
|
+
timer-driven `Type=oneshot` mid-run and is left alone (`isUnitRunning`, unit-form.ts).
|
|
160
162
|
- ⛔ A **masked** unit is someone's decision, not drift: refused, never unmasked.
|
|
161
163
|
- ⛔ Deleting the declaration **stops the service** — stop, disable, remove the
|
|
162
164
|
file, reload. A unit that must outlive its declaration is adopted, not deleted.
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Deploying a Linux host as an unprivileged ssh user — `sshSudoRunner()`
|
|
2
|
+
|
|
3
|
+
The Linux twin of [`sudoRunner()`](./launchd-sudo.md): the deploy runs as the ssh
|
|
4
|
+
user, and only a fixed allowlist of calls goes through `sudo -n`, in exact argv
|
|
5
|
+
shapes, logging each one. It is an explicit opt-in — `sshRunner()` stays the
|
|
6
|
+
default and never elevates (docs/linux-host.md).
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { linuxProviders, sshSudoRunner } from '@homeflare/alchemy/linux';
|
|
10
|
+
|
|
11
|
+
const runner = await sshSudoRunner({
|
|
12
|
+
host: 'n2',
|
|
13
|
+
// ⛔ Required: the directories root may write. Each — every directory above
|
|
14
|
+
// it, and every directory below it on the way to a file — must be one
|
|
15
|
+
// that root owns and only root may write, by mode bits and by POSIX ACL;
|
|
16
|
+
// checked at every privileged call.
|
|
17
|
+
prefixes: ['/etc/systemd/system', '/usr/local/bin'],
|
|
18
|
+
// log: (line) => …, // default: one line on stderr per privileged call
|
|
19
|
+
});
|
|
20
|
+
// Provide linuxProviders(runner) alongside the stack's other provider layers.
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
⚠️ **Target: an ssh user that is NOT root, with passwordless sudo** —
|
|
24
|
+
`(ALL) NOPASSWD: ALL` on the sudoers line, as `homeflare-proxmox` declares for
|
|
25
|
+
`tim` on each Proxmox node. `sshRunner()` already covers an ssh user that IS
|
|
26
|
+
root; this is for the other case.
|
|
27
|
+
|
|
28
|
+
## Why `install` alone isn't enough here
|
|
29
|
+
|
|
30
|
+
macOS's `sudoRunner()` writes a file with one privileged call: `install -S`
|
|
31
|
+
stages a temp file beside the target and renames it, atomically, inside
|
|
32
|
+
`install` itself. **GNU `install` has no such mode** — it opens the
|
|
33
|
+
destination and writes through it (`src/linux/ssh-scripts.ts`'s own header).
|
|
34
|
+
So this runner does the staging and the atomic swap itself:
|
|
35
|
+
|
|
36
|
+
| step | who | what |
|
|
37
|
+
| ---- | ---------- | ------------------------------------------------------------------- |
|
|
38
|
+
| 1 | operator | `mktemp -d` (0700), then `writeFileAtomic` the bytes into it (0600) |
|
|
39
|
+
| 2 | **root** | `install -m <4 octal> -T -- <staged> <temp>` — no `-o`/`-g`, ever |
|
|
40
|
+
| 3 | **root**\* | `chown +<uid>[:+<gid>]` — only when a non-root owner was declared |
|
|
41
|
+
| 4 | **root** | `mv -f -T -- <temp> <dest>` — rename(2), same directory, atomic |
|
|
42
|
+
|
|
43
|
+
`<temp>` is `<dest's directory>/.<basename>.hf-<12 hex>.tmp`, derived per
|
|
44
|
+
call and verified **absent** by the operator before step 2 runs. `install`
|
|
45
|
+
never takes `-o`/`-g`: unlike `chown`, GNU `install`'s own `get_ids()`
|
|
46
|
+
(coreutils `src/install.c`) always tries `getpwnam`/`getgrnam` on the string
|
|
47
|
+
FIRST, with no way to force numeric parsing — so `install -o 0` on a host
|
|
48
|
+
that ever had a user literally named `"0"` would install owned by THAT
|
|
49
|
+
account, not uid 0. `install` therefore always leaves the temp owned by
|
|
50
|
+
whoever `sudo` ran it as (root:root), and a declared non-root owner or group
|
|
51
|
+
is set afterward by step 3's `chown`, which DOES support forcing numeric
|
|
52
|
+
parsing (`+<id>`, skipping the name lookup — gnulib's `userspec.c`). `mv` is
|
|
53
|
+
the one call that ever touches `<dest>`, and rename(2) is atomic by
|
|
54
|
+
construction. On any failure after step 2, the temp is removed with
|
|
55
|
+
`sudo -n rm -f -- <temp>` — if THAT also fails, the thrown error says so and
|
|
56
|
+
names the path, rather than leaving it a silent mystery. The operator's own
|
|
57
|
+
staging directory is removed either way.
|
|
58
|
+
|
|
59
|
+
## What runs as root: the whole list
|
|
60
|
+
|
|
61
|
+
Each is `/usr/bin/sudo -n -- <argv>`, every program by absolute path.
|
|
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>` |
|
|
75
|
+
|
|
76
|
+
`enable`/`disable`/`start`/`stop`/`restart` elevate only when the unit is not
|
|
77
|
+
**masked** (`LoadState`, read alongside `FragmentPath` in the same call —
|
|
78
|
+
masking is someone's decision, never overridden here) and its **own
|
|
79
|
+
`FragmentPath`** is either under a declared prefix, or empty AND the verb is
|
|
80
|
+
`stop`/`disable` — the only two `unit-lifecycle.ts` `deleteUnit` ever sends
|
|
81
|
+
against a unit whose file it may just have removed, so a second, idempotent
|
|
82
|
+
delete pass still works. `enable`/`start`/`restart` with an empty
|
|
83
|
+
`FragmentPath` are refused, closing a kernel-generated pseudo-unit
|
|
84
|
+
(`init.scope`, a `session-N.scope`) as a target. A vendor unit like
|
|
85
|
+
`pveproxy.service`, whose `FragmentPath` is `/usr/lib/systemd/system/…`, is
|
|
86
|
+
refused before sudo ever runs: this runner cannot touch what it did not
|
|
87
|
+
declare. `--user`, `--global`, `-H`, `-M` and `--root` are refused outright,
|
|
88
|
+
anywhere in the argv. `directory-lifecycle.ts`'s own `chown` argv is bare
|
|
89
|
+
(`0`, `:0`, `0:0`); `canonicalize()` rewrites it to the `+`-forced form above
|
|
90
|
+
before the allowlist ever sees it, so the unprivileged caller never has to
|
|
91
|
+
know that syntax exists.
|
|
92
|
+
|
|
93
|
+
## What stays as the operator
|
|
94
|
+
|
|
95
|
+
- Every read: `stat`, `readFile`, user/group lookups, `systemctl show`.
|
|
96
|
+
- `checkWrite` — the plan-time half of a write, run at every plan so a
|
|
97
|
+
refusal surfaces before any resource is applied, never at the write.
|
|
98
|
+
- Files and directories outside every prefix (a foreign owner there is a
|
|
99
|
+
refusal naming the declared prefixes, not a silent write attempt).
|
|
100
|
+
- `ls -ldn` and `mktemp -d` — the guard's chain read and the write's own
|
|
101
|
+
staging, both unprivileged (`src/linux/sudo-listing.ts`, `sudo-stage.ts`).
|
|
102
|
+
|
|
103
|
+
★ **So a plan never calls sudo**, and construction never does either:
|
|
104
|
+
`sshSudoRunner()` runs one combined, read-only `test -x` for the nine
|
|
105
|
+
absolute programs above, so a missing coreutils package or a systemd-free
|
|
106
|
+
container fails with a clear message at construction — never a sudo probe.
|
|
107
|
+
|
|
108
|
+
## The host guard
|
|
109
|
+
|
|
110
|
+
Before any privileged file or directory call, and in `checkWrite`, one
|
|
111
|
+
`ls -ldn` read (as the operator) covers every directory from `/` down to the
|
|
112
|
+
target's parent (`src/linux/sudo-listing.ts` — one ssh round trip for the
|
|
113
|
+
whole chain, because ssh pays a handshake per call with no multiplexing
|
|
114
|
+
unless the caller opts in). Refused: any directory in that chain not owned
|
|
115
|
+
by root, or writable by group or other; a POSIX ACL `+` flag anywhere in it
|
|
116
|
+
(the signal is the flag alone — `getfacl` is absent on the estate's Debian
|
|
117
|
+
hosts, so an ACL is refused whatever it grants, fail closed); a symlink at or
|
|
118
|
+
below the prefix; a target of the wrong kind for what is about to happen to
|
|
119
|
+
it (a directory sitting where a file write expects absent or a file, say).
|
|
120
|
+
A root-owned symlink **above** the prefix (Debian's merged-`/usr`, `/lib` ->
|
|
121
|
+
`usr/lib`) is the system's own and is followed, mirroring the Mac guard.
|
|
122
|
+
|
|
123
|
+
## Refused before sudo is asked
|
|
124
|
+
|
|
125
|
+
Same spirit as [launchd-sudo.md](./launchd-sudo.md)'s list: any argv whose
|
|
126
|
+
program is `sudo`; `install` with `-o`/`-g` at all (refused outright — see
|
|
127
|
+
above); `install` from a source it did not stage, or to a target that is not
|
|
128
|
+
the temp it derived; `chown` on the temp with a bare (non-`+`-forced) id, a
|
|
129
|
+
name, or a path that is not that exact temp; `mv` whose source is not that
|
|
130
|
+
same temp, or whose destination is not under a declared prefix; `rm -r`; any
|
|
131
|
+
of the directory programs with an extra operand, a relative path, `..`, a
|
|
132
|
+
prefix-sibling (`/usr/local/bin-x` is not under `/usr/local/bin`), or a
|
|
133
|
+
`chown` by name, a bare digit, or any non-root id; `systemctl mask`/`--user`;
|
|
134
|
+
a masked unit, any verb; `enable`/`start`/`restart` on a unit with no unit
|
|
135
|
+
file; a root-owned file that would be group- or world-writable, setuid or
|
|
136
|
+
setgid; a file the operator could not read back (both `Systemd.Unit` and
|
|
137
|
+
`Remote.File` read every write back to compare digests).
|
|
138
|
+
|
|
139
|
+
🔴 **Adversarial review, 2026-09-23, two rounds — everything found is fixed
|
|
140
|
+
and tested.** Round 1: a directory this runner elevates was checked for
|
|
141
|
+
SHAPE only, never mode or owner. `mkdir -m 0777` and `chown <non-root uid>`
|
|
142
|
+
both passed untouched, so a stack could declare `HostDirectory({ path:
|
|
143
|
+
'/etc/systemd/system/x.service.d', mode: 0o777 })`, drop an `ExecStart=`
|
|
144
|
+
override into it, and this runner's own next `daemon-reload` + `restart`
|
|
145
|
+
would run it as root. Fixed: a directory this runner elevates is now always
|
|
146
|
+
root-owned and never setuid/setgid or group/other-writable, unconditionally
|
|
147
|
+
— a directory's OWNER always has write access through the owner bits alone,
|
|
148
|
+
so no mode restriction alone could make a non-root-owned directory safe
|
|
149
|
+
here. The same round tightened the shared `modeProblem` check
|
|
150
|
+
(`../launchd/sudo-guard.ts`, used by both platforms): it exempted a file
|
|
151
|
+
merely because its `uid` was non-root, missing that `{uid: 501, gid: 0,
|
|
152
|
+
mode: 0o2775}` — setgid to root's own group — reaches the same escalation
|
|
153
|
+
through the group instead. Round 2 found the round-1 `modeProblem` fix was
|
|
154
|
+
itself incomplete (an OMITTED `gid`, not just an explicit `0`, defaults to
|
|
155
|
+
root's own group under `install` — see the section above), the masked-unit
|
|
156
|
+
and FragmentPath-scope gaps documented above, `install`'s own numeric-id
|
|
157
|
+
ambiguity (also above), and a swallowed cleanup-`rm` failure (now included
|
|
158
|
+
in the thrown error, with the leftover path named). All covered by tests:
|
|
159
|
+
`sudo-allowlist.test.ts`, `sudo-lifecycle.test.ts`, `sudo-runner.test.ts`,
|
|
160
|
+
`../launchd/sudo-modes.test.ts`.
|
|
161
|
+
|
|
162
|
+
Every refusal throws `SudoRefusedError`: nothing ran as root. A privileged
|
|
163
|
+
command that ran and failed throws a plain `Error` with its exit code.
|
|
164
|
+
|
|
165
|
+
## What is measured vs reasoned
|
|
166
|
+
|
|
167
|
+
⚠️ **REASONED, NOT MEASURED — never run live** unless marked otherwise: the
|
|
168
|
+
write path (`install`→`mv`, `rm`, `mkdir`/`chmod`/`chown`/`rmdir`,
|
|
169
|
+
`daemon-reload`/`enable`/`disable`/`start`/`stop`/`restart`) has never been
|
|
170
|
+
run against a real host by this runner. Each is checked for exit 0 and its
|
|
171
|
+
effect read back, never assumed — the same discipline
|
|
172
|
+
[linux-host-measured.md](./linux-host-measured.md) already keeps for the
|
|
173
|
+
unprivileged writes.
|
|
174
|
+
|
|
175
|
+
★ **Exercised read-only against `n2` (Debian 13 / trixie, systemd 257
|
|
176
|
+
(257.13-1~deb13u1), coreutils 9.7-3, merged `/usr` — `/bin -> usr/bin`), ssh
|
|
177
|
+
user `tim` uid 1000, `(ALL) NOPASSWD: ALL`, 2026-09-23.** `sshSudoRunner()`
|
|
178
|
+
built successfully (the `test -x` construction check passed for all nine
|
|
179
|
+
programs); `checkWrite('/etc/systemd/system/hf-pve-cluster-check.service',
|
|
180
|
+
{mode:0o644, uid:0, gid:0})` and
|
|
181
|
+
`checkWrite('/usr/local/bin/hf-pve-cluster-check', {mode:0o755, uid:0,
|
|
182
|
+
gid:0})` both returned successfully (the real directory chain read as
|
|
183
|
+
root-owned, no group/other write, no ACL, both targets absent); a read-only
|
|
184
|
+
`systemctl show -p LoadState -- hf-pve-cluster-check.service` answered
|
|
185
|
+
`LoadState=not-found`. **The runner's own log stayed empty across every
|
|
186
|
+
call** — nothing was elevated. Nothing was written, enabled, started or
|
|
187
|
+
reloaded on `n2`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.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",
|