@homeflare/alchemy 0.54.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.
Files changed (62) hide show
  1. package/dist/ceph/ceph-argv-config.d.ts +6 -3
  2. package/dist/ceph/ceph-argv-config.d.ts.map +1 -1
  3. package/dist/ceph/ceph-argv.d.ts +8 -0
  4. package/dist/ceph/ceph-argv.d.ts.map +1 -1
  5. package/dist/ceph/ceph-config-option-form.d.ts +48 -0
  6. package/dist/ceph/ceph-config-option-form.d.ts.map +1 -0
  7. package/dist/ceph/ceph-config-option-reconcile.d.ts +22 -0
  8. package/dist/ceph/ceph-config-option-reconcile.d.ts.map +1 -0
  9. package/dist/ceph/ceph-config-options.d.ts +15 -6
  10. package/dist/ceph/ceph-config-options.d.ts.map +1 -1
  11. package/dist/ceph/config-option.d.ts +10 -0
  12. package/dist/ceph/config-option.d.ts.map +1 -0
  13. package/dist/ceph/index.d.ts +7 -3
  14. package/dist/ceph/index.d.ts.map +1 -1
  15. package/dist/ceph/index.js +363 -8
  16. package/dist/ceph/index.js.map +17 -7
  17. package/dist/launchd/index.js +4 -2
  18. package/dist/launchd/index.js.map +3 -3
  19. package/dist/launchd/sudo-guard-mode.d.ts +10 -4
  20. package/dist/launchd/sudo-guard-mode.d.ts.map +1 -1
  21. package/dist/linux/fake-sudo-root.d.ts.map +1 -1
  22. package/dist/linux/fake-sudo.d.ts +9 -1
  23. package/dist/linux/fake-sudo.d.ts.map +1 -1
  24. package/dist/linux/index.js +47 -9
  25. package/dist/linux/index.js.map +6 -6
  26. package/dist/linux/sudo-guard.d.ts +10 -1
  27. package/dist/linux/sudo-guard.d.ts.map +1 -1
  28. package/dist/linux/sudo-runner.d.ts +15 -1
  29. package/dist/linux/sudo-runner.d.ts.map +1 -1
  30. package/dist/linux/sudo-write.d.ts +4 -1
  31. package/dist/linux/sudo-write.d.ts.map +1 -1
  32. package/dist/proxmox/index.d.ts +1 -0
  33. package/dist/proxmox/index.d.ts.map +1 -1
  34. package/dist/proxmox/index.js +101 -1
  35. package/dist/proxmox/index.js.map +5 -4
  36. package/dist/proxmox/scoped-api.d.ts +49 -0
  37. package/dist/proxmox/scoped-api.d.ts.map +1 -0
  38. package/docs/linux-sudo-review-2026-09-23.md +27 -0
  39. package/docs/linux-sudo.md +28 -40
  40. package/docs/pve-scoped-api.md +23 -0
  41. package/package.json +1 -1
  42. package/src/ceph/ceph-argv-config.ts +27 -19
  43. package/src/ceph/ceph-argv.test.ts +37 -1
  44. package/src/ceph/ceph-argv.ts +25 -0
  45. package/src/ceph/ceph-config-option-form.ts +73 -0
  46. package/src/ceph/ceph-config-option-reconcile.ts +88 -0
  47. package/src/ceph/ceph-config-option.test.ts +213 -0
  48. package/src/ceph/ceph-config-options.test.ts +28 -11
  49. package/src/ceph/ceph-config-options.ts +29 -8
  50. package/src/ceph/config-option.ts +112 -0
  51. package/src/ceph/index.ts +7 -3
  52. package/src/launchd/sudo-guard-mode.ts +11 -3
  53. package/src/linux/fake-sudo-root.ts +15 -0
  54. package/src/linux/fake-sudo.ts +20 -1
  55. package/src/linux/sudo-guard.ts +22 -1
  56. package/src/linux/sudo-runner-privileged-read.test.ts +200 -0
  57. package/src/linux/sudo-runner.ts +59 -4
  58. package/src/linux/sudo-write.ts +5 -1
  59. package/src/proxmox/index.ts +13 -0
  60. package/src/proxmox/scoped-api.test.ts +52 -0
  61. package/src/proxmox/scoped-api.ts +148 -0
  62. package/src/talos/talosctl.test.ts +6 -4
@@ -0,0 +1,200 @@
1
+ /**
2
+ * sshSudoRunner `privilegedReads`: an exact-path, root-owned-regular-file-only sudo read, so a
3
+ * plan can diff a declared 0600 file without a general root-read primitive.
4
+ */
5
+ import { describe, expect, test } from 'bun:test';
6
+ import type { FileStat, HostRunner } from '../launchd/runner.ts';
7
+ import { CHOWN, INSTALL, SUDO } from './sudo-allowlist.ts';
8
+ import { fakeSudoHost } from './fake-sudo.ts';
9
+ import {
10
+ BASE64,
11
+ SudoRefusedError,
12
+ checkPrivilegedReads,
13
+ makeSshSudoRunner,
14
+ } from './sudo-runner.ts';
15
+
16
+ const SECRETISH = '/opt/app/config.yaml';
17
+ const file = (uid: number, kind: FileStat['kind'] = 'file'): FileStat =>
18
+ ({ kind, mode: 0o600, uid, gid: 0, size: 5 }) as unknown as FileStat;
19
+
20
+ const host = (stat: FileStat | undefined, exitCode = 0) => {
21
+ const execs: string[][] = [];
22
+ const operatorReads: string[] = [];
23
+ const base = {
24
+ platform: 'linux',
25
+ privileged: false,
26
+ effectiveUid: () => 1001,
27
+ stat: async () => stat,
28
+ readFile: async (path: string) => {
29
+ operatorReads.push(path);
30
+ return new TextEncoder().encode('operator');
31
+ },
32
+ exec: async (argv: string[]) => {
33
+ execs.push(argv);
34
+ return {
35
+ exitCode,
36
+ stdout: `${Buffer.from('hello').toString('base64')}\n`,
37
+ stderr: exitCode === 0 ? '' : 'boom',
38
+ };
39
+ },
40
+ } as unknown as HostRunner;
41
+ const runner = makeSshSudoRunner(['/opt/app/data'], {
42
+ base,
43
+ log: () => undefined,
44
+ stage: async () => {
45
+ throw new Error('unused');
46
+ },
47
+ privilegedReads: [SECRETISH],
48
+ });
49
+ return { execs, operatorReads, runner };
50
+ };
51
+
52
+ describe('privilegedReads', () => {
53
+ test('a listed root-owned file is read with exactly sudo -n -- base64 -- <path>', async () => {
54
+ const { execs, runner } = host(file(0));
55
+ expect(new TextDecoder().decode(await runner.readFile(SECRETISH))).toBe('hello');
56
+ expect(execs).toEqual([[SUDO, '-n', '--', BASE64, '--', SECRETISH]]);
57
+ });
58
+
59
+ test('an unlisted path is read as the operator, never elevated', async () => {
60
+ const { execs, operatorReads, runner } = host(file(0));
61
+ await runner.readFile('/etc/caddy/cf-token.env');
62
+ await runner.readFile(`${SECRETISH}.bak`);
63
+ await runner.readFile('/opt/app');
64
+ expect(execs).toEqual([]);
65
+ expect(operatorReads).toEqual(['/etc/caddy/cf-token.env', `${SECRETISH}.bak`, '/opt/app']);
66
+ });
67
+
68
+ test('a symlink, a directory or a non-root file at the listed path is refused', async () => {
69
+ for (const stat of [file(0, 'symlink'), file(0, 'directory'), file(1001)]) {
70
+ const { execs, runner } = host(stat);
71
+ await expect(runner.readFile(SECRETISH)).rejects.toBeInstanceOf(SudoRefusedError);
72
+ expect(execs).toEqual([]);
73
+ }
74
+ });
75
+
76
+ test('an absent listed file reads as absent', async () => {
77
+ const { execs, runner } = host(undefined);
78
+ expect(await runner.readFile(SECRETISH)).toBeUndefined();
79
+ expect(execs).toEqual([]);
80
+ });
81
+
82
+ test('a failed sudo read throws', async () => {
83
+ const { runner } = host(file(0), 1);
84
+ await expect(runner.readFile(SECRETISH)).rejects.toThrow('-> 1');
85
+ });
86
+
87
+ test('only normalized absolute paths may be listed', () => {
88
+ for (const bad of ['relative', '/opt/../etc/shadow', '/opt//x', '/opt/x/', '/opt/./x', '/']) {
89
+ expect(() => checkPrivilegedReads([bad])).toThrow(SudoRefusedError);
90
+ }
91
+ expect(checkPrivilegedReads([SECRETISH]).has(SECRETISH)).toBe(true);
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
+ });
@@ -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 NEVER ELEVATES. `checkWrite` and every read run through `base` — the plain
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.
@@ -44,6 +46,14 @@ export type SshSudoRunnerOptions = SshRunnerOptions & {
44
46
  readonly prefixes: readonly string[];
45
47
  /** One line per privileged argv, before it runs. @default a line on stderr */
46
48
  readonly log?: (line: string) => void;
49
+ /**
50
+ * ★ Exact absolute paths of root-owned files the operator cannot read but a plan must diff
51
+ * (a declared 0600 file). Each is read with `sudo -n -- /usr/bin/base64 -- <path>`, and ONLY
52
+ * when it is the exact string listed here, a regular file (lstat: never a symlink) and owned
53
+ * by uid 0. ⛔ Not a prefix and not a pattern: a general sudo read would be a root-read
54
+ * primitive reaching every credential on the host, so each file is named one by one.
55
+ */
56
+ readonly privilegedReads?: readonly string[];
47
57
  };
48
58
 
49
59
  /** What a sudo runner is built from. ★ Split out so the tests swap the host, never the logic. */
@@ -52,8 +62,31 @@ export type SshSudoDeps = {
52
62
  readonly base: HostRunner;
53
63
  readonly stage: (bytes: Uint8Array) => Promise<Staged>;
54
64
  readonly log: (line: string) => void;
65
+ readonly privilegedReads?: readonly string[];
55
66
  };
56
67
 
68
+ export const BASE64 = '/usr/bin/base64';
69
+
70
+ /** Refuses anything but a normalized absolute path: no `.`/`..` segment, no `//`, no trailing `/`. */
71
+ export const checkPrivilegedReads = (paths: readonly string[] = []): ReadonlySet<string> => {
72
+ for (const path of paths) {
73
+ const segments = path.split('/').slice(1);
74
+ if (
75
+ !path.startsWith('/') ||
76
+ path.endsWith('/') ||
77
+ segments.some((segment) => segment === '' || segment === '.' || segment === '..') ||
78
+ /[\0\n]/.test(path)
79
+ )
80
+ throw new SudoRefusedError(
81
+ `sshSudoRunner: privilegedReads entry ${JSON.stringify(path)} is not a normalized absolute file path`,
82
+ );
83
+ }
84
+ return new Set(paths);
85
+ };
86
+
87
+ const decodeBase64 = (text: string): Uint8Array =>
88
+ new Uint8Array(Buffer.from(text.replaceAll('\n', ''), 'base64'));
89
+
57
90
  const outsidePrefixHint =
58
91
  (path: string, prefixes: readonly string[]) =>
59
92
  (cause: unknown): never => {
@@ -70,6 +103,7 @@ const outsidePrefixHint =
70
103
  export const makeSshSudoRunner = (prefixList: readonly string[], deps: SshSudoDeps): HostRunner => {
71
104
  const prefixes = checkPrefixes(prefixList);
72
105
  const { base } = deps;
106
+ const readable = checkPrivilegedReads(deps.privilegedReads);
73
107
 
74
108
  /** ⛔ THE ONE PLACE `sudo` IS SPAWNED: allowlist, then any host check (`before`), then the log
75
109
  * line, then `sudo -n -- argv`. Shared by exec()'s root branch and by writeFileAtomic. */
@@ -88,7 +122,7 @@ export const makeSshSudoRunner = (prefixList: readonly string[], deps: SshSudoDe
88
122
 
89
123
  return {
90
124
  checkWrite: async (path, options) => {
91
- await vetWrite(base, prefixes, path, options);
125
+ await vetWrite(base, prefixes, path, options, readable);
92
126
  },
93
127
  effectiveUid: () => base.effectiveUid(),
94
128
  exec: async (argv) => {
@@ -103,7 +137,27 @@ export const makeSshSudoRunner = (prefixList: readonly string[], deps: SshSudoDe
103
137
  // this runner elevates on the SAME target `base` (sshRunner) already declared.
104
138
  platform: base.platform,
105
139
  privileged: true,
106
- readFile: (path) => base.readFile(path),
140
+ readFile: async (path) => {
141
+ if (!readable.has(path)) return base.readFile(path);
142
+ const stat = await base.stat(path);
143
+ if (stat === undefined) return undefined;
144
+ if (stat.kind !== 'file' || stat.uid !== 0)
145
+ throw new SudoRefusedError(
146
+ `sshSudoRunner refused a privileged read of ${path}: it is a ${stat.kind} owned by uid ` +
147
+ `${String(stat.uid)}, and only a root-owned regular file is read as root`,
148
+ );
149
+ const argv = [BASE64, '--', path];
150
+ const shown = JSON.stringify(argv);
151
+ deps.log(`homeflare/linux sudo -n ${shown}`);
152
+ const result = await base.exec([SUDO, '-n', '--', ...argv]);
153
+ const refused = sudoRefusal(shown, result);
154
+ if (refused !== undefined) throw refused;
155
+ if (result.exitCode !== 0)
156
+ throw new Error(
157
+ `sudo -n ${shown} -> ${String(result.exitCode)}: ${result.stderr.trim().slice(0, 300)}`,
158
+ );
159
+ return decodeBase64(result.stdout);
160
+ },
107
161
  removeFile: async (path) => {
108
162
  const prefix = prefixOf(path, prefixes);
109
163
  if (prefix === undefined)
@@ -122,7 +176,7 @@ export const makeSshSudoRunner = (prefixList: readonly string[], deps: SshSudoDe
122
176
  sleep: (ms) => base.sleep(ms),
123
177
  stat: (path) => base.stat(path),
124
178
  writeFileAtomic: async (path, bytes, options) => {
125
- const vetted = await vetWrite(base, prefixes, path, options);
179
+ const vetted = await vetWrite(base, prefixes, path, options, readable);
126
180
  if (vetted === undefined) {
127
181
  return base.writeFileAtomic(path, bytes, options).catch(outsidePrefixHint(path, prefixes));
128
182
  }
@@ -157,6 +211,7 @@ export const sshSudoRunner = async (options: SshSudoRunnerOptions): Promise<Host
157
211
  await checkProgramsExist(base, options.host);
158
212
  return makeSshSudoRunner(options.prefixes, {
159
213
  base,
214
+ ...(options.privilegedReads === undefined ? {} : { privilegedReads: options.privilegedReads }),
160
215
  log:
161
216
  options.log ??
162
217
  ((line) => {
@@ -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
 
@@ -130,3 +130,16 @@ export { ProxmoxStorage, ProxmoxStorageProvider } from './storage.ts';
130
130
  export { ProxmoxUser, ProxmoxUserProvider } from './user.ts';
131
131
  export { type FromEnv } from '../secrets/write-only.ts';
132
132
  export { ProxmoxZfsPool, ProxmoxZfsPoolProvider } from './zfs-pool.ts';
133
+
134
+ // ★ 2026-10-14: scoped PVE API helper for Ceph / CT ops without SSH (scoped-api.ts).
135
+ export {
136
+ CEPH_FLAGS,
137
+ CT_CONFIG_KEYS,
138
+ type CephFlag,
139
+ type CtConfigPatch,
140
+ type ScopedCall,
141
+ type ScopedTier,
142
+ pveScoped,
143
+ scopedCalls,
144
+ scopedTarget,
145
+ } from './scoped-api.ts';
@@ -0,0 +1,52 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import { type PveTarget, mintTier } from './credentials.ts';
4
+ import { CT_CONFIG_KEYS, scopedCalls, scopedTarget } from './scoped-api.ts';
5
+
6
+ const target: PveTarget = { mount: 'proxmox-tb4', members: ['n2.mgmt.example'], scheme: 'pve' };
7
+
8
+ describe('pveScoped', () => {
9
+ it('pins BOTH roles to the scoped tier, so neither read nor provision can be minted', () => {
10
+ for (const tier of ['ceph-ops', 'ct-config'] as const) {
11
+ const t = scopedTarget(target, tier);
12
+ assert.equal(mintTier(t, 'read'), tier);
13
+ assert.equal(mintTier(t, 'provision'), tier);
14
+ }
15
+ });
16
+ it('ceph calls are fixed paths on the ceph-ops tier', () => {
17
+ assert.deepEqual(scopedCalls.cephStatus(), {
18
+ tier: 'ceph-ops',
19
+ role: 'read',
20
+ method: 'GET',
21
+ path: '/cluster/ceph/status',
22
+ });
23
+ assert.deepEqual(scopedCalls.cephSetFlag('noout', false), {
24
+ tier: 'ceph-ops',
25
+ role: 'provision',
26
+ method: 'PUT',
27
+ path: '/cluster/ceph/flags/noout',
28
+ form: { value: '0' },
29
+ });
30
+ assert.equal(scopedCalls.cephOsd('n3', 13, 'in').path, '/nodes/n3/ceph/osd/13/in');
31
+ });
32
+ it('refuses flags, nodes, ids and actions outside the closed set', () => {
33
+ assert.throws(() => scopedCalls.cephSetFlag('pause' as never, true));
34
+ assert.throws(() => scopedCalls.cephOsd('../n2', 1, 'in'));
35
+ assert.throws(() => scopedCalls.cephOsd('n2', -1, 'out'));
36
+ assert.throws(() => scopedCalls.ctConfig('n2', 99));
37
+ assert.throws(() => scopedCalls.ctPower('n2', 100, 'destroy' as never));
38
+ });
39
+ it('ct config writes only the keys HFCTConfig covers, on the ct-config tier', () => {
40
+ const c = scopedCalls.ctSetConfig('n2', 100, { cores: 8, memory: 16384, onboot: true });
41
+ assert.deepEqual(c, {
42
+ tier: 'ct-config',
43
+ role: 'provision',
44
+ method: 'PUT',
45
+ path: '/nodes/n2/lxc/100/config',
46
+ form: { cores: '8', memory: '16384', onboot: '1' },
47
+ });
48
+ assert.throws(() => scopedCalls.ctSetConfig('n2', 100, { rootfs: 'x' } as never));
49
+ assert.throws(() => scopedCalls.ctSetConfig('n2', 100, {}));
50
+ assert.ok(!(CT_CONFIG_KEYS as readonly string[]).includes('rootfs'));
51
+ });
52
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * `pveScoped`: the agent helper for Ceph and container operations over the PVE API when SSH to a
3
+ * node is not available (2026-10-14, "PVE API path").
4
+ *
5
+ * ★ EVERY CALL MINTS A SCOPED TIER, NEVER `provision`. A call names its tier (`ceph-ops` or
6
+ * `ct-config`); `scopedTarget` pins BOTH of the target's roles to that tier (credentials.ts's
7
+ * "set both roles or neither"), so no read or write in it can fall back to the estate-wide tier.
8
+ * In the estate those tiers are `proxmox-tb4/creds/{ceph-ops,ct-config}` → hf-ceph-ops@pve
9
+ * (HFCephOps on `/`) and hf-ct-config@pve (HFCTConfig on `/vms`); the PVE ACL is the boundary.
10
+ * ★ A CLOSED SET OF OPERATIONS. Each builder returns a fixed method + path, with every path
11
+ * segment validated, and `ctSetConfig` accepts only the keys HFCTConfig can change anyway.
12
+ * No free-form path, so an agent cannot turn this into a general PVE client.
13
+ * ⛔ The minted secret never leaves `mint`/`pve()` (mint.ts's ⛔). Results are PVE's `data`.
14
+ * ⛔ Not a substitute for Alchemy: a change that should persist (a declared CT's cores, a Ceph
15
+ * flag that a stack owns) still lands as a reviewed PR. This is the break-glass read/act path.
16
+ */
17
+ import type * as Effect from 'effect/Effect';
18
+ import type * as HttpClient from 'effect/http/HttpClient';
19
+ import type { PveCredentialDenied } from './credential-errors.ts';
20
+ import { type PveForm, pve } from './client.ts';
21
+ import type { PveRole, PveTarget } from './credentials.ts';
22
+
23
+ export type ScopedTier = 'ceph-ops' | 'ct-config';
24
+
25
+ export type ScopedCall = {
26
+ readonly tier: ScopedTier;
27
+ readonly role: PveRole;
28
+ readonly method: 'GET' | 'POST' | 'PUT';
29
+ readonly path: string;
30
+ readonly form?: PveForm;
31
+ };
32
+
33
+ /** The target with BOTH roles pinned to `tier`: nothing here can mint `read`/`provision`. */
34
+ export const scopedTarget = (target: PveTarget, tier: ScopedTier): PveTarget => ({
35
+ ...target,
36
+ roles: { read: tier, provision: tier },
37
+ });
38
+
39
+ const NODE = /^[a-z0-9][a-z0-9-]{0,62}$/;
40
+ const need = (ok: boolean, what: string) => {
41
+ if (!ok) throw new Error(`pveScoped: invalid ${what}`);
42
+ };
43
+ const node = (n: string) => (need(NODE.test(n), 'node name'), n);
44
+ const vmid = (v: number) => (
45
+ need(Number.isInteger(v) && v >= 100 && v <= 999_999_999, 'vmid'),
46
+ String(v)
47
+ );
48
+ const osd = (o: number) => (
49
+ need(Number.isInteger(o) && o >= 0 && o < 100_000, 'osd id'),
50
+ String(o)
51
+ );
52
+
53
+ /** Flags an operator sets around maintenance. `pause`, `full` and friends are deliberately absent. */
54
+ export const CEPH_FLAGS = [
55
+ 'noout',
56
+ 'norebalance',
57
+ 'norecover',
58
+ 'nobackfill',
59
+ 'noscrub',
60
+ 'nodeep-scrub',
61
+ ] as const;
62
+ export type CephFlag = (typeof CEPH_FLAGS)[number];
63
+
64
+ /** The CT config keys `ctSetConfig` may write: exactly what HFCTConfig's VM.Config.* privs cover. */
65
+ export const CT_CONFIG_KEYS = [
66
+ 'cores',
67
+ 'cpulimit',
68
+ 'memory',
69
+ 'swap',
70
+ 'onboot',
71
+ 'description',
72
+ 'tags',
73
+ ] as const;
74
+ export type CtConfigPatch = Partial<
75
+ Record<(typeof CT_CONFIG_KEYS)[number], string | number | boolean>
76
+ >;
77
+
78
+ export const scopedCalls = {
79
+ cephStatus: (): ScopedCall => ({
80
+ tier: 'ceph-ops',
81
+ role: 'read',
82
+ method: 'GET',
83
+ path: '/cluster/ceph/status',
84
+ }),
85
+ cephFlags: (): ScopedCall => ({
86
+ tier: 'ceph-ops',
87
+ role: 'read',
88
+ method: 'GET',
89
+ path: '/cluster/ceph/flags',
90
+ }),
91
+ cephSetFlag: (flag: CephFlag, on: boolean): ScopedCall => {
92
+ need((CEPH_FLAGS as readonly string[]).includes(flag), 'ceph flag');
93
+ return {
94
+ tier: 'ceph-ops',
95
+ role: 'provision',
96
+ method: 'PUT',
97
+ path: `/cluster/ceph/flags/${flag}`,
98
+ form: { value: on ? '1' : '0' },
99
+ };
100
+ },
101
+ cephOsd: (n: string, id: number, to: 'in' | 'out'): ScopedCall => {
102
+ need(to === 'in' || to === 'out', 'osd direction');
103
+ return {
104
+ tier: 'ceph-ops',
105
+ role: 'provision',
106
+ method: 'POST',
107
+ path: `/nodes/${node(n)}/ceph/osd/${osd(id)}/${to}`,
108
+ };
109
+ },
110
+ ctConfig: (n: string, id: number): ScopedCall => ({
111
+ tier: 'ct-config',
112
+ role: 'read',
113
+ method: 'GET',
114
+ path: `/nodes/${node(n)}/lxc/${vmid(id)}/config`,
115
+ }),
116
+ ctSetConfig: (n: string, id: number, patch: CtConfigPatch): ScopedCall => {
117
+ const keys = Object.keys(patch);
118
+ need(keys.length > 0, 'empty config patch');
119
+ for (const k of keys)
120
+ need((CT_CONFIG_KEYS as readonly string[]).includes(k), `config key ${k}`);
121
+ const form: Record<string, string> = {};
122
+ for (const [k, v] of Object.entries(patch))
123
+ form[k] = typeof v === 'boolean' ? (v ? '1' : '0') : String(v);
124
+ return {
125
+ tier: 'ct-config',
126
+ role: 'provision',
127
+ method: 'PUT',
128
+ path: `/nodes/${node(n)}/lxc/${vmid(id)}/config`,
129
+ form,
130
+ };
131
+ },
132
+ ctPower: (n: string, id: number, action: 'start' | 'shutdown' | 'reboot'): ScopedCall => {
133
+ need(['start', 'shutdown', 'reboot'].includes(action), 'power action');
134
+ return {
135
+ tier: 'ct-config',
136
+ role: 'provision',
137
+ method: 'POST',
138
+ path: `/nodes/${node(n)}/lxc/${vmid(id)}/status/${action}`,
139
+ };
140
+ },
141
+ } as const;
142
+
143
+ /** Run one scoped call: mint its tier (only), call PVE, return `data`. */
144
+ export const pveScoped = <T = unknown>(
145
+ target: PveTarget,
146
+ call: ScopedCall,
147
+ ): Effect.Effect<T | undefined, Error | PveCredentialDenied, HttpClient.HttpClient> =>
148
+ pve<T>(scopedTarget(target, call.tier), call.role, call.method, call.path, call.form);
@@ -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(fixture);
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(fixture);
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([fixture, fixture]);
89
+ expect(calls.map((call) => call.command)).toEqual([resolved, resolved]);
88
90
  }),
89
91
  );
90
92
  });