@homeflare/alchemy 0.25.1 → 0.27.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 (118) hide show
  1. package/dist/github/declare-repo-baseline.d.ts +11 -0
  2. package/dist/github/declare-repo-baseline.d.ts.map +1 -0
  3. package/dist/github/index.d.ts +20 -11
  4. package/dist/github/index.d.ts.map +1 -1
  5. package/dist/github/index.js +715 -4
  6. package/dist/github/index.js.map +14 -4
  7. package/dist/github/repo-baseline-data.d.ts +75 -0
  8. package/dist/github/repo-baseline-data.d.ts.map +1 -0
  9. package/dist/github/repo-policy.d.ts +4 -4
  10. package/dist/github/repo-policy.d.ts.map +1 -1
  11. package/dist/github/repository-ruleset-constraints.d.ts +105 -0
  12. package/dist/github/repository-ruleset-constraints.d.ts.map +1 -0
  13. package/dist/github/repository-ruleset-errors.d.ts +97 -0
  14. package/dist/github/repository-ruleset-errors.d.ts.map +1 -0
  15. package/dist/github/repository-ruleset-fake-octokit.d.ts +24 -0
  16. package/dist/github/repository-ruleset-fake-octokit.d.ts.map +1 -0
  17. package/dist/github/repository-ruleset-fixtures.d.ts +20 -0
  18. package/dist/github/repository-ruleset-fixtures.d.ts.map +1 -0
  19. package/dist/github/repository-ruleset-form.d.ts +30 -0
  20. package/dist/github/repository-ruleset-form.d.ts.map +1 -0
  21. package/dist/github/repository-ruleset-guards.d.ts +70 -0
  22. package/dist/github/repository-ruleset-guards.d.ts.map +1 -0
  23. package/dist/github/repository-ruleset-octokit.d.ts +5 -0
  24. package/dist/github/repository-ruleset-octokit.d.ts.map +1 -0
  25. package/dist/github/repository-ruleset-probe.d.ts +87 -0
  26. package/dist/github/repository-ruleset-probe.d.ts.map +1 -0
  27. package/dist/github/repository-ruleset-reconcile.d.ts +24 -0
  28. package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -0
  29. package/dist/github/repository-ruleset-test-helpers.d.ts +32 -0
  30. package/dist/github/repository-ruleset-test-helpers.d.ts.map +1 -0
  31. package/dist/github/repository-ruleset.d.ts +89 -0
  32. package/dist/github/repository-ruleset.d.ts.map +1 -0
  33. package/dist/launchd/index.js +51 -47
  34. package/dist/launchd/index.js.map +5 -4
  35. package/dist/launchd/sudo-guard-mode.d.ts +62 -0
  36. package/dist/launchd/sudo-guard-mode.d.ts.map +1 -0
  37. package/dist/launchd/sudo-guard.d.ts +1 -38
  38. package/dist/launchd/sudo-guard.d.ts.map +1 -1
  39. package/dist/linux/fake-sudo-root.d.ts +26 -0
  40. package/dist/linux/fake-sudo-root.d.ts.map +1 -0
  41. package/dist/linux/fake-sudo.d.ts +56 -0
  42. package/dist/linux/fake-sudo.d.ts.map +1 -0
  43. package/dist/linux/index.d.ts +2 -0
  44. package/dist/linux/index.d.ts.map +1 -1
  45. package/dist/linux/index.js +1134 -2
  46. package/dist/linux/index.js.map +22 -5
  47. package/dist/linux/sudo-allowlist-chown.d.ts +12 -0
  48. package/dist/linux/sudo-allowlist-chown.d.ts.map +1 -0
  49. package/dist/linux/sudo-allowlist-dir.d.ts +6 -0
  50. package/dist/linux/sudo-allowlist-dir.d.ts.map +1 -0
  51. package/dist/linux/sudo-allowlist.d.ts +40 -0
  52. package/dist/linux/sudo-allowlist.d.ts.map +1 -0
  53. package/dist/linux/sudo-exec.d.ts +31 -0
  54. package/dist/linux/sudo-exec.d.ts.map +1 -0
  55. package/dist/linux/sudo-guard.d.ts +36 -0
  56. package/dist/linux/sudo-guard.d.ts.map +1 -0
  57. package/dist/linux/sudo-runner.d.ts +37 -0
  58. package/dist/linux/sudo-runner.d.ts.map +1 -0
  59. package/dist/linux/sudo-stage.d.ts +22 -0
  60. package/dist/linux/sudo-stage.d.ts.map +1 -0
  61. package/dist/linux/sudo-write.d.ts +25 -0
  62. package/dist/linux/sudo-write.d.ts.map +1 -0
  63. package/dist/linux/unit-form.d.ts +2 -0
  64. package/dist/linux/unit-form.d.ts.map +1 -1
  65. package/dist/litellm/fake-litellm.d.ts.map +1 -1
  66. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -1
  67. package/docs/launchd-sudo.md +12 -3
  68. package/docs/linux-host.md +3 -3
  69. package/docs/linux-sudo.md +187 -0
  70. package/docs/repo-policy-ruleset-hazards.md +8 -0
  71. package/docs/repo-policy.md +27 -12
  72. package/docs/repository-ruleset.md +114 -0
  73. package/docs/upstream-conformance.md +12 -1
  74. package/docs/upstream-pr-ruleset-fixes.md +77 -0
  75. package/package.json +1 -1
  76. package/src/github/declare-repo-baseline.ts +42 -0
  77. package/src/github/index.ts +43 -11
  78. package/src/github/repo-baseline-data.test.ts +76 -0
  79. package/src/github/repo-baseline-data.ts +86 -0
  80. package/src/github/repo-policy.ts +17 -17
  81. package/src/github/repository-ruleset-constraints.ts +136 -0
  82. package/src/github/repository-ruleset-coverage.test.ts +82 -0
  83. package/src/github/repository-ruleset-errors.ts +136 -0
  84. package/src/github/repository-ruleset-fake-octokit.ts +131 -0
  85. package/src/github/repository-ruleset-fixtures.ts +183 -0
  86. package/src/github/repository-ruleset-form.ts +212 -0
  87. package/src/github/repository-ruleset-guards.ts +194 -0
  88. package/src/github/repository-ruleset-lifecycle.test.ts +172 -0
  89. package/src/github/repository-ruleset-octokit.ts +140 -0
  90. package/src/github/repository-ruleset-probe.ts +116 -0
  91. package/src/github/repository-ruleset-reconcile.ts +176 -0
  92. package/src/github/repository-ruleset-test-helpers.ts +102 -0
  93. package/src/github/repository-ruleset-wire.test.ts +66 -0
  94. package/src/github/repository-ruleset-write-refusals.test.ts +199 -0
  95. package/src/github/repository-ruleset-write.test.ts +83 -0
  96. package/src/github/repository-ruleset.ts +219 -0
  97. package/src/launchd/sudo-guard-mode.ts +111 -0
  98. package/src/launchd/sudo-guard.ts +3 -79
  99. package/src/launchd/sudo-modes.test.ts +38 -1
  100. package/src/linux/fake-sudo-root.ts +157 -0
  101. package/src/linux/fake-sudo.ts +142 -0
  102. package/src/linux/index.ts +2 -0
  103. package/src/linux/sudo-allowlist-chown.ts +71 -0
  104. package/src/linux/sudo-allowlist-dir.ts +103 -0
  105. package/src/linux/sudo-allowlist.test.ts +252 -0
  106. package/src/linux/sudo-allowlist.ts +223 -0
  107. package/src/linux/sudo-exec.ts +118 -0
  108. package/src/linux/sudo-guard.test.ts +136 -0
  109. package/src/linux/sudo-guard.ts +142 -0
  110. package/src/linux/sudo-lifecycle.test.ts +151 -0
  111. package/src/linux/sudo-runner.test.ts +206 -0
  112. package/src/linux/sudo-runner.ts +164 -0
  113. package/src/linux/sudo-stage.ts +50 -0
  114. package/src/linux/sudo-write.ts +167 -0
  115. package/src/linux/unit-form.ts +2 -1
  116. package/src/litellm/fake-litellm.ts +2 -0
  117. package/src/proxmox/client.test.ts +17 -3
  118. 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"}
@@ -107,6 +107,8 @@ export declare const isUnitRunning: (activeState: string, subState: string | und
107
107
  export declare const renderUnit: (sections: readonly UnitSection[]) => string;
108
108
  export declare const unitText: (props: SystemdUnitProps) => string;
109
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;
110
112
  /**
111
113
  * Every refusal at once, so one plan shows the whole list.
112
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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;AAoB/D;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,UAAW,gBAAgB,WAAW,MAAM,KAAG,MAAM,EAmC7E,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":"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,WAyEH,CAAC"}
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,OA2H1B,CAAC;AAEF,8FAA8F;AAC9F,eAAO,MAAM,QAAQ,SAAU,OAAO,aAG0C,CAAC"}
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"}
@@ -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 is root). ⛔ Anyone in that class could rewrite a file root installed (a
83
- daemon's config, a script it runs), and a setuid root file runs as root for whoever executes it.
84
- A file handed to another user (`owner`) is theirs to change, so its mode is theirs too.
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
@@ -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 a
34
- privileged runner of your own. The Linux twin of `sudoRunner()` — with its
35
- own argv allowlist — is deliberately a separate, later change.
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
 
@@ -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`.
@@ -1,5 +1,13 @@
1
1
  # `declareRepoPolicy` — the ruleset half's hazards
2
2
 
3
+ ⚠️ **Status 2026-09-23: `declareRepoPolicy` no longer takes this path.** It is rewired onto
4
+ `GitHub.RepositoryRuleset` — the bridge resource in `repository-ruleset.ts` — which closes the
5
+ duplicate-on-first-deploy hazard below by probing for a same-named ruleset before ever creating
6
+ one, and fixes the never-noop hazard by normalizing before comparing (see
7
+ [repository-ruleset.md](./repository-ruleset.md)). Everything on this page is still an accurate
8
+ account of upstream `GitHub.Ruleset` itself, kept for anyone who declares it directly rather
9
+ than through this kit, and as the record of why the bridge exists.
10
+
3
11
  Everything here is about `GitHub.Ruleset` in `alchemy@2.0.0-beta.79`, not about the
4
12
  policy this kit declares. It is extracted from [repo-policy.md](./repo-policy.md) so the
5
13
  reasoning stays whole rather than being shaved to fit a line cap.
@@ -9,7 +9,7 @@ import { declareRepoPolicy } from '@homeflare/alchemy/github';
9
9
  import * as Effect from 'effect/Effect';
10
10
 
11
11
  Effect.gen(function* () {
12
- // policy.repository → GitHub.Repository, policy.ruleset → GitHub.Ruleset
12
+ // policy.repository → GitHub.Repository, policy.ruleset → GitHub.RepositoryRuleset
13
13
  const policy = yield* declareRepoPolicy('api', {
14
14
  owner: 'my-org',
15
15
  repository: 'api',
@@ -18,8 +18,12 @@ Effect.gen(function* () {
18
18
  });
19
19
  ```
20
20
 
21
- Provide `GitHub.providers()`. This subpath ships **no provider of its own** — everything
22
- here composes Alchemy's first-class `GitHub.Repository` and `GitHub.Ruleset`.
21
+ Provide `GitHub.providers()` **and** `RepositoryRulesetProvider()` from this subpath.
22
+ ⚠️ **Rewired 2026-09-23**: the ruleset half now composes this house's own
23
+ `GitHub.RepositoryRuleset` (see [repository-ruleset.md](./repository-ruleset.md)), not
24
+ upstream `GitHub.Ruleset` — every hazard [below](#-the-ruleset-half-has-hazards-of-its-own)
25
+ describes upstream's behavior and is now what this bridge exists to close, not what
26
+ `declareRepoPolicy` still does.
23
27
 
24
28
  `repoPolicy(options)` is the same thing without the resources: the two prop objects, pure,
25
29
  for a test or for a stack that wants to declare them itself.
@@ -105,16 +109,22 @@ not name a job behind a `paths:` filter, a matrix leg, or anything a skipped wor
105
109
  silence. Contexts are trimmed, de-duplicated and **sorted**, so writing the same two the
106
110
  other way round is not a diff.
107
111
 
108
- ## ⛔ The ruleset half has hazards of its own
112
+ ## ⛔ The ruleset half's hazards — closed by the bridge, still worth reading
109
113
 
110
114
  The vendor `Ruleset` **cannot be adopted** — a first deploy onto a repository that
111
115
  already has a ruleset of that name creates a **second** one beside it, both enforcing —
112
116
  it **never plans a no-op**, and it replaces its `rules` and `bypass_actors` **wholesale**.
113
- None of that applies to the repository half.
117
+ None of that ever applied to the repository half.
114
118
 
115
- ⛔ Read [repo-policy-ruleset-hazards.md](./repo-policy-ruleset-hazards.md) before the
116
- first deploy. It carries the preflight `gh api repos/<owner>/<repo>/rulesets` command and
117
- the repository-half-only escape hatch.
119
+ As of the 2026-09-23 rewire, `declareRepoPolicy`'s ruleset half goes through
120
+ `GitHub.RepositoryRuleset`, which probes by name before creating and normalizes before
121
+ comparing — see [repository-ruleset.md](./repository-ruleset.md). The wholesale-replace
122
+ behavior is unchanged (it is how the underlying `PUT` works), now enforced as a named
123
+ refusal rather than a silent drop.
124
+
125
+ ⛔ Read [repo-policy-ruleset-hazards.md](./repo-policy-ruleset-hazards.md) for the full
126
+ account of upstream `GitHub.Ruleset`'s own behavior — still accurate for anyone who
127
+ declares it directly rather than through this kit.
118
128
 
119
129
  ## Required reviews are opt-in, and `0` is refused
120
130
 
@@ -125,10 +135,15 @@ default, where a pull request is gated by its checks.
125
135
 
126
136
  ⛔ `requiredApprovals: 0` is **refused**, not treated as "no reviews". Zero approvals is
127
137
  the solo-maintainer shape — every change through a pull request, no second person — and
128
- it works only with `require_extra_approval_for_unattributed_changes: false`. Alchemy's
129
- `Ruleset` has no property for that field, and GitHub defaults it to `true` when it is
130
- omitted (measured 2026-09-17), which blocks the very pull requests the zero was meant to
131
- let through. Accepting it would ship a rule that does the opposite of what it says.
138
+ it works only with `require_extra_approval_for_unattributed_changes: false`, which GitHub
139
+ defaults to `true` when it is omitted (measured 2026-09-17), blocking the very pull
140
+ requests the zero was meant to let through.
141
+ ⚠️ **Still refused after the 2026-09-23 rewire**, though the underlying resource
142
+ (`GitHub.RepositoryRuleset`) can now send that flag — `RepoPolicyOptions` has no field to
143
+ ask for it, so `repoPolicy` would otherwise ship a rule that does the opposite of what it
144
+ says. A caller that genuinely wants `0` declares `RepositoryRuleset` directly with
145
+ `extraApprovalForUnattributedChanges: false` (see
146
+ [repository-ruleset.md](./repository-ruleset.md)) instead of going through this helper.
132
147
 
133
148
  ## ★ `strict` is always off, and is not a parameter
134
149