@homeflare/alchemy 0.53.1 → 0.55.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 (58) 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/github/fork-pr-approval.d.ts +55 -0
  18. package/dist/github/fork-pr-approval.d.ts.map +1 -0
  19. package/dist/github/index.d.ts +1 -0
  20. package/dist/github/index.d.ts.map +1 -1
  21. package/dist/github/index.js +87 -4
  22. package/dist/github/index.js.map +6 -5
  23. package/dist/github/repo-baseline-data.d.ts +3 -2
  24. package/dist/github/repo-baseline-data.d.ts.map +1 -1
  25. package/dist/github/repository-ruleset-providers.d.ts +1 -1
  26. package/dist/github/repository-ruleset-providers.d.ts.map +1 -1
  27. package/dist/linux/index.js +33 -2
  28. package/dist/linux/index.js.map +3 -3
  29. package/dist/linux/sudo-runner.d.ts +12 -0
  30. package/dist/linux/sudo-runner.d.ts.map +1 -1
  31. package/dist/proxmox/index.d.ts +1 -0
  32. package/dist/proxmox/index.d.ts.map +1 -1
  33. package/dist/proxmox/index.js +101 -1
  34. package/dist/proxmox/index.js.map +5 -4
  35. package/dist/proxmox/scoped-api.d.ts +49 -0
  36. package/dist/proxmox/scoped-api.d.ts.map +1 -0
  37. package/docs/pve-scoped-api.md +23 -0
  38. package/package.json +1 -1
  39. package/src/ceph/ceph-argv-config.ts +27 -19
  40. package/src/ceph/ceph-argv.test.ts +37 -1
  41. package/src/ceph/ceph-argv.ts +25 -0
  42. package/src/ceph/ceph-config-option-form.ts +73 -0
  43. package/src/ceph/ceph-config-option-reconcile.ts +88 -0
  44. package/src/ceph/ceph-config-option.test.ts +213 -0
  45. package/src/ceph/ceph-config-options.test.ts +28 -11
  46. package/src/ceph/ceph-config-options.ts +29 -8
  47. package/src/ceph/config-option.ts +112 -0
  48. package/src/ceph/index.ts +7 -3
  49. package/src/github/fork-pr-approval.test.ts +74 -0
  50. package/src/github/fork-pr-approval.ts +174 -0
  51. package/src/github/index.ts +9 -0
  52. package/src/github/repo-baseline-data.ts +2 -2
  53. package/src/github/repository-ruleset-providers.ts +6 -1
  54. package/src/linux/sudo-runner-privileged-read.test.ts +92 -0
  55. package/src/linux/sudo-runner.ts +54 -1
  56. package/src/proxmox/index.ts +13 -0
  57. package/src/proxmox/scoped-api.test.ts +52 -0
  58. package/src/proxmox/scoped-api.ts +148 -0
@@ -44,6 +44,14 @@ export type SshSudoRunnerOptions = SshRunnerOptions & {
44
44
  readonly prefixes: readonly string[];
45
45
  /** One line per privileged argv, before it runs. @default a line on stderr */
46
46
  readonly log?: (line: string) => void;
47
+ /**
48
+ * ★ Exact absolute paths of root-owned files the operator cannot read but a plan must diff
49
+ * (a declared 0600 file). Each is read with `sudo -n -- /usr/bin/base64 -- <path>`, and ONLY
50
+ * when it is the exact string listed here, a regular file (lstat: never a symlink) and owned
51
+ * by uid 0. ⛔ Not a prefix and not a pattern: a general sudo read would be a root-read
52
+ * primitive reaching every credential on the host, so each file is named one by one.
53
+ */
54
+ readonly privilegedReads?: readonly string[];
47
55
  };
48
56
 
49
57
  /** What a sudo runner is built from. ★ Split out so the tests swap the host, never the logic. */
@@ -52,8 +60,31 @@ export type SshSudoDeps = {
52
60
  readonly base: HostRunner;
53
61
  readonly stage: (bytes: Uint8Array) => Promise<Staged>;
54
62
  readonly log: (line: string) => void;
63
+ readonly privilegedReads?: readonly string[];
55
64
  };
56
65
 
66
+ export const BASE64 = '/usr/bin/base64';
67
+
68
+ /** Refuses anything but a normalized absolute path: no `.`/`..` segment, no `//`, no trailing `/`. */
69
+ export const checkPrivilegedReads = (paths: readonly string[] = []): ReadonlySet<string> => {
70
+ for (const path of paths) {
71
+ const segments = path.split('/').slice(1);
72
+ if (
73
+ !path.startsWith('/') ||
74
+ path.endsWith('/') ||
75
+ segments.some((segment) => segment === '' || segment === '.' || segment === '..') ||
76
+ /[\0\n]/.test(path)
77
+ )
78
+ throw new SudoRefusedError(
79
+ `sshSudoRunner: privilegedReads entry ${JSON.stringify(path)} is not a normalized absolute file path`,
80
+ );
81
+ }
82
+ return new Set(paths);
83
+ };
84
+
85
+ const decodeBase64 = (text: string): Uint8Array =>
86
+ new Uint8Array(Buffer.from(text.replaceAll('\n', ''), 'base64'));
87
+
57
88
  const outsidePrefixHint =
58
89
  (path: string, prefixes: readonly string[]) =>
59
90
  (cause: unknown): never => {
@@ -70,6 +101,7 @@ const outsidePrefixHint =
70
101
  export const makeSshSudoRunner = (prefixList: readonly string[], deps: SshSudoDeps): HostRunner => {
71
102
  const prefixes = checkPrefixes(prefixList);
72
103
  const { base } = deps;
104
+ const readable = checkPrivilegedReads(deps.privilegedReads);
73
105
 
74
106
  /** ⛔ THE ONE PLACE `sudo` IS SPAWNED: allowlist, then any host check (`before`), then the log
75
107
  * line, then `sudo -n -- argv`. Shared by exec()'s root branch and by writeFileAtomic. */
@@ -103,7 +135,27 @@ export const makeSshSudoRunner = (prefixList: readonly string[], deps: SshSudoDe
103
135
  // this runner elevates on the SAME target `base` (sshRunner) already declared.
104
136
  platform: base.platform,
105
137
  privileged: true,
106
- readFile: (path) => base.readFile(path),
138
+ readFile: async (path) => {
139
+ if (!readable.has(path)) return base.readFile(path);
140
+ const stat = await base.stat(path);
141
+ if (stat === undefined) return undefined;
142
+ if (stat.kind !== 'file' || stat.uid !== 0)
143
+ throw new SudoRefusedError(
144
+ `sshSudoRunner refused a privileged read of ${path}: it is a ${stat.kind} owned by uid ` +
145
+ `${String(stat.uid)}, and only a root-owned regular file is read as root`,
146
+ );
147
+ const argv = [BASE64, '--', path];
148
+ const shown = JSON.stringify(argv);
149
+ deps.log(`homeflare/linux sudo -n ${shown}`);
150
+ const result = await base.exec([SUDO, '-n', '--', ...argv]);
151
+ const refused = sudoRefusal(shown, result);
152
+ if (refused !== undefined) throw refused;
153
+ if (result.exitCode !== 0)
154
+ throw new Error(
155
+ `sudo -n ${shown} -> ${String(result.exitCode)}: ${result.stderr.trim().slice(0, 300)}`,
156
+ );
157
+ return decodeBase64(result.stdout);
158
+ },
107
159
  removeFile: async (path) => {
108
160
  const prefix = prefixOf(path, prefixes);
109
161
  if (prefix === undefined)
@@ -157,6 +209,7 @@ export const sshSudoRunner = async (options: SshSudoRunnerOptions): Promise<Host
157
209
  await checkProgramsExist(base, options.host);
158
210
  return makeSshSudoRunner(options.prefixes, {
159
211
  base,
212
+ ...(options.privilegedReads === undefined ? {} : { privilegedReads: options.privilegedReads }),
160
213
  log:
161
214
  options.log ??
162
215
  ((line) => {
@@ -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);