@homeflare/alchemy 0.55.0 → 0.57.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 (65) hide show
  1. package/dist/launchd/index.js +4 -2
  2. package/dist/launchd/index.js.map +3 -3
  3. package/dist/launchd/sudo-guard-mode.d.ts +10 -4
  4. package/dist/launchd/sudo-guard-mode.d.ts.map +1 -1
  5. package/dist/linux/fake-sudo-root.d.ts.map +1 -1
  6. package/dist/linux/fake-sudo.d.ts +9 -1
  7. package/dist/linux/fake-sudo.d.ts.map +1 -1
  8. package/dist/linux/index.js +15 -8
  9. package/dist/linux/index.js.map +6 -6
  10. package/dist/linux/sudo-guard.d.ts +10 -1
  11. package/dist/linux/sudo-guard.d.ts.map +1 -1
  12. package/dist/linux/sudo-runner.d.ts +3 -1
  13. package/dist/linux/sudo-runner.d.ts.map +1 -1
  14. package/dist/linux/sudo-write.d.ts +4 -1
  15. package/dist/linux/sudo-write.d.ts.map +1 -1
  16. package/dist/unifi/acl-rule-ordering.d.ts +1 -1
  17. package/dist/unifi/acl-rule.d.ts +1 -1
  18. package/dist/unifi/dns-policy.d.ts +1 -1
  19. package/dist/unifi/firewall-policy-create.d.ts +55 -0
  20. package/dist/unifi/firewall-policy-create.d.ts.map +1 -0
  21. package/dist/unifi/firewall-policy-form.d.ts +9 -2
  22. package/dist/unifi/firewall-policy-form.d.ts.map +1 -1
  23. package/dist/unifi/firewall-policy-ordering.d.ts +1 -1
  24. package/dist/unifi/firewall-policy.d.ts +45 -12
  25. package/dist/unifi/firewall-policy.d.ts.map +1 -1
  26. package/dist/unifi/firewall-zone.d.ts +1 -1
  27. package/dist/unifi/index.d.ts +1 -1
  28. package/dist/unifi/index.d.ts.map +1 -1
  29. package/dist/unifi/index.js +112 -21
  30. package/dist/unifi/index.js.map +10 -9
  31. package/dist/unifi/network.d.ts +1 -1
  32. package/dist/unifi/policy.d.ts +22 -0
  33. package/dist/unifi/policy.d.ts.map +1 -1
  34. package/dist/unifi/resource.d.ts +26 -7
  35. package/dist/unifi/resource.d.ts.map +1 -1
  36. package/dist/unifi/traffic-matching-list.d.ts +1 -1
  37. package/dist/unifi/update-reconcile.d.ts +36 -2
  38. package/dist/unifi/update-reconcile.d.ts.map +1 -1
  39. package/dist/unifi/wifi-broadcast.d.ts +1 -1
  40. package/dist/unifi/wire-guard.d.ts +1 -1
  41. package/dist/unifi/wire-guard.d.ts.map +1 -1
  42. package/docs/distilled-interim.md +4 -2
  43. package/docs/linux-sudo-review-2026-09-23.md +27 -0
  44. package/docs/linux-sudo.md +28 -40
  45. package/package.json +1 -1
  46. package/src/launchd/sudo-guard-mode.ts +11 -3
  47. package/src/linux/fake-sudo-root.ts +15 -0
  48. package/src/linux/fake-sudo.ts +20 -1
  49. package/src/linux/sudo-guard.ts +22 -1
  50. package/src/linux/sudo-runner-privileged-read.test.ts +109 -1
  51. package/src/linux/sudo-runner.ts +5 -3
  52. package/src/linux/sudo-write.ts +5 -1
  53. package/src/talos/talosctl.test.ts +6 -4
  54. package/src/unifi/firewall-policy-create.ts +94 -0
  55. package/src/unifi/firewall-policy-form.ts +9 -2
  56. package/src/unifi/firewall-policy.test.ts +188 -1
  57. package/src/unifi/firewall-policy.ts +108 -12
  58. package/src/unifi/index.ts +1 -0
  59. package/src/unifi/policy.ts +45 -12
  60. package/src/unifi/resource.test.ts +103 -0
  61. package/src/unifi/resource.ts +73 -21
  62. package/src/unifi/update-reconcile.ts +72 -2
  63. package/src/unifi/wire-guard.test.ts +86 -1
  64. package/src/unifi/wire-guard.ts +8 -1
  65. package/src/unifi/write-op-reference.test.ts +21 -7
@@ -80,7 +80,7 @@ export declare const handlers: {
80
80
  session: {
81
81
  note: (message: string) => Effect.Effect<void, never, never>;
82
82
  };
83
- }) => Effect.Effect<NetworkAttributes, import("@distilled.cloud/caddy").BadGateway | import("@distilled.cloud/caddy").BadRequest | import("@distilled.cloud/caddy").ConfigError | import("@distilled.cloud/caddy").Conflict | import("@distilled.cloud/caddy").Forbidden | import("@distilled.cloud/caddy").GatewayTimeout | import("effect/http/HttpClientError").HttpClientError | import("@distilled.cloud/caddy").InternalServerError | import("@distilled.cloud/caddy").NotFound | import("@distilled.cloud/caddy").ServiceUnavailable | import("@distilled.cloud/caddy").TooManyRequests | import("@distilled.cloud/caddy").Unauthorized | import("./policy.ts").UnifiFieldNotRemovable | import("./policy.ts").UnifiFieldNotSettable | import("./policy.ts").UnifiIdentityChanged | import("./policy.ts").UnifiImmutableFieldChanged | import("./policy.ts").UnifiLiveDriftedSinceDeploy | import("./policy.ts").UnifiManagementNetworkRefused | import("@distilled.cloud/unifi-network/Errors").UnifiNetworkParseError | import("./policy.ts").UnifiUpdateDidNotConverge | UnifiUpdateWouldBeNoop | import("./policy.ts").UnifiWriteRefused | import("@distilled.cloud/unifi-network/Errors").UnknownUnifiNetworkError | import("@distilled.cloud/caddy").UnprocessableEntity, import("effect/http/HttpClient").HttpClient>;
83
+ }) => Effect.Effect<NetworkAttributes, import("@distilled.cloud/caddy").BadGateway | import("@distilled.cloud/caddy").BadRequest | import("@distilled.cloud/caddy").ConfigError | import("@distilled.cloud/caddy").Conflict | import("@distilled.cloud/caddy").Forbidden | import("@distilled.cloud/caddy").GatewayTimeout | import("effect/http/HttpClientError").HttpClientError | import("@distilled.cloud/caddy").InternalServerError | import("@distilled.cloud/caddy").NotFound | import("@distilled.cloud/caddy").ServiceUnavailable | import("@distilled.cloud/caddy").TooManyRequests | import("@distilled.cloud/caddy").Unauthorized | import("./policy.ts").UnifiCreateDidNotConverge | import("./policy.ts").UnifiFieldNotRemovable | import("./policy.ts").UnifiFieldNotSettable | import("./policy.ts").UnifiIdentityChanged | import("./policy.ts").UnifiImmutableFieldChanged | import("./policy.ts").UnifiLiveDriftedSinceDeploy | import("./policy.ts").UnifiManagementNetworkRefused | import("@distilled.cloud/unifi-network/Errors").UnifiNetworkParseError | import("./policy.ts").UnifiUpdateDidNotConverge | UnifiUpdateWouldBeNoop | import("./policy.ts").UnifiWriteRefused | import("@distilled.cloud/unifi-network/Errors").UnknownUnifiNetworkError | import("@distilled.cloud/caddy").UnprocessableEntity, import("effect/http/HttpClient").HttpClient>;
84
84
  delete: (args: {
85
85
  olds: NetworkProps;
86
86
  }) => Effect.Effect<never, import("./policy.ts").UnifiWriteRefused, import("effect/http/HttpClient").HttpClient>;
@@ -1,5 +1,13 @@
1
1
  import * as Effect from 'effect/Effect';
2
2
  export declare const UNIFI_WRITE_POLICY: string;
3
+ /**
4
+ * ⛔ THE ONE GATE THAT DECIDES "hf:" (Tim 2026-10-19, `nas-firewall`'s own naming:
5
+ * `hf: CT100 to NAS https`). The space after the colon is deliberate and checked: `"hf:foo"`
6
+ * (no space) is NOT an hf: name under this policy, so a typo can never silently slip past the
7
+ * create gate by omitting it. Exported so `firewall-policy-create.ts`'s `checkScope` and every
8
+ * test that proves the boundary use this ONE function, never a second regex hand-copied nearby.
9
+ */
10
+ export declare const isHfPolicyName: (name: string) => boolean;
3
11
  /** @deprecated renamed `UNIFI_WRITE_POLICY`; kept so existing consumer imports do not break. */
4
12
  export declare const UNIFI_READ_ONLY_POLICY: string;
5
13
  /** What kind of write was asked for and refused — for the message only, never acted on. */
@@ -50,6 +58,20 @@ export declare class UnifiUpdateDidNotConverge extends UnifiUpdateDidNotConverge
50
58
  }> {
51
59
  get message(): string;
52
60
  }
61
+ declare const UnifiCreateDidNotConverge_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
62
+ readonly _tag: "UnifiCreateDidNotConverge";
63
+ } & Readonly<A>;
64
+ /** The POST answered, a read-back ran, yet the read-back object still differs from the
65
+ * declaration — the hf: create path's own convergence check (`create-reconcile.ts`'s sibling in
66
+ * `update-reconcile.ts`), same shape as `UnifiUpdateDidNotConverge` for the same reason: the
67
+ * object now exists on the console, unrolled-back, so the operator needs to know and re-import. */
68
+ export declare class UnifiCreateDidNotConverge extends UnifiCreateDidNotConverge_base<{
69
+ readonly type: string;
70
+ readonly identity: string;
71
+ readonly fields: ReadonlyArray<string>;
72
+ }> {
73
+ get message(): string;
74
+ }
53
75
  declare const UnifiFieldNotRemovable_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
54
76
  readonly _tag: "UnifiFieldNotRemovable";
55
77
  } & Readonly<A>;
@@ -1 +1 @@
1
- {"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../../src/unifi/policy.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../../src/unifi/policy.ts"],"names":[],"mappings":"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"}
@@ -2,7 +2,7 @@ import type { Input } from 'alchemy/Input';
2
2
  import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Protocol';
3
3
  import * as Effect from 'effect/Effect';
4
4
  import type * as HttpClient from 'effect/http/HttpClient';
5
- import { type ReconcileArgs, type UnifiUpdate } from './update-reconcile.ts';
5
+ import { type ReconcileArgs, type UnifiCreate, type UnifiUpdate } from './update-reconcile.ts';
6
6
  /** What every handler needs from the caller's runtime once `CredentialsFromEnv` is baked in. */
7
7
  export type UnifiRequirements = HttpClient.HttpClient;
8
8
  /**
@@ -11,7 +11,7 @@ export type UnifiRequirements = HttpClient.HttpClient;
11
11
  * union, with `NotFound` already folded away) so the precise per-operation error union flows
12
12
  * through instead of being widened by hand here. `E2` is the error union of the optional write.
13
13
  */
14
- export type UnifiSpec<Props extends object, Live, Attributes extends object, E, E2 = never> = {
14
+ export type UnifiSpec<Props extends object, Live, Attributes extends object, E, E2 = never, E3 = never> = {
15
15
  /** The vendor type string, e.g. `Unifi.Network` — for the refusal message only. */
16
16
  readonly type: string;
17
17
  /** Already folds a genuine not-found to `undefined`; nothing else is folded (see header). */
@@ -22,8 +22,11 @@ export type UnifiSpec<Props extends object, Live, Attributes extends object, E,
22
22
  readonly describe: (props: Props) => string;
23
23
  /** Present only on a family allowed to write (`Unifi.Network`); see `update-reconcile.ts`. */
24
24
  readonly update?: UnifiUpdate<Props, Live, E2>;
25
+ /** Present only on a family allowed to create (`Unifi.FirewallPolicy`, `hf:` rows only); see
26
+ * `update-reconcile.ts`'s `createReconcile`. */
27
+ readonly create?: UnifiCreate<Props, Live, E3>;
25
28
  };
26
- export declare const unifiOperations: <Props extends object, Live, Attributes extends object, E, E2>(spec: UnifiSpec<Props, Live, Attributes, E, E2>) => {
29
+ export declare const unifiOperations: <Props extends object, Live, Attributes extends object, E, E2, E3>(spec: UnifiSpec<Props, Live, Attributes, E, E2, E3>) => {
27
30
  read: (props: Props) => Effect.Effect<Attributes | undefined, E, UnifiNetworkOpContext>;
28
31
  readHandler: ({ olds, output }: {
29
32
  olds: Props;
@@ -34,8 +37,24 @@ export declare const unifiOperations: <Props extends object, Live, Attributes ex
34
37
  } | {
35
38
  readonly action: 'noop';
36
39
  } | undefined, E, UnifiNetworkOpContext>;
37
- /** Pre-state callers (tests, adopt-only probes) pass just `news`: `olds`/`output` unset. */
38
- reconcile: (news: Props, rest?: Partial<Omit<ReconcileArgs<Props>, 'news'>>) => Effect.Effect<Attributes, E | E2 | import("./policy.ts").UnifiIdentityChanged | import("./policy.ts").UnifiLiveDriftedSinceDeploy | import("./policy.ts").UnifiUpdateDidNotConverge | import("./policy.ts").UnifiWriteRefused, UnifiNetworkOpContext>;
40
+ /**
41
+ * Pre-state callers (tests, adopt-only probes) pass just `news`: `olds`/`output` unset — the
42
+ * SAME calling convention a plain cold adoption already uses (H6), so the existence GET must
43
+ * run FIRST, for every name, exactly as it always has: a row that already exists is adopted
44
+ * (exact match, zero writes) regardless of its name, and ONLY an absent row ever reaches
45
+ * `create.checkScope`. "A non-hf: create sends nothing" therefore means no WRITE (no
46
+ * POST/PUT/DELETE) — the same guarantee every other refusal in this engine gives, and the one
47
+ * `wire-guard.ts`'s allow list enforces at the wire; the existence GET is not a write.
48
+ *
49
+ * ⛔ THE CREATE BRANCH ONLY RUNS WHEN THERE IS NO PRIOR STATE AT ALL (`olds`/`output` both
50
+ * absent — a genuine first deploy, never a re-declare of an adopted row) AND the family
51
+ * declares `create` AND the live read comes back absent. A row that already exists (someone
52
+ * else's object with a colliding identity, or a retried deploy that already created it)
53
+ * takes the ordinary `updateReconcile` path instead — exact match reports with zero writes,
54
+ * anything else refuses (there is no `olds` to PUT a patch against, so it can never silently
55
+ * fall through to an update of an existing row).
56
+ */
57
+ reconcile: (news: Props, rest?: Partial<Omit<ReconcileArgs<Props>, 'news'>>) => Effect.Effect<Attributes, E | E2 | E3 | import("./policy.ts").UnifiCreateDidNotConverge | import("./policy.ts").UnifiIdentityChanged | import("./policy.ts").UnifiLiveDriftedSinceDeploy | import("./policy.ts").UnifiUpdateDidNotConverge | import("./policy.ts").UnifiWriteRefused, UnifiNetworkOpContext>;
39
58
  destroy: (olds: Props) => Effect.Effect<never, import("./policy.ts").UnifiWriteRefused, never>;
40
59
  };
41
60
  /**
@@ -44,7 +63,7 @@ export declare const unifiOperations: <Props extends object, Live, Attributes ex
44
63
  * ⛔ `list` ANSWERS EMPTY. `GET /v1/sites/{siteId}/networks` answers every network on the site;
45
64
  * adoption stays explicit, the same reasoning `Proxmox.User`'s and NetBox's `list` give.
46
65
  */
47
- export declare const unifiHandlers: <Props extends object, Live, Attributes extends object, E, E2>(spec: UnifiSpec<Props, Live, Attributes, E, E2>) => {
66
+ export declare const unifiHandlers: <Props extends object, Live, Attributes extends object, E, E2, E3>(spec: UnifiSpec<Props, Live, Attributes, E, E2, E3>) => {
48
67
  list: () => Effect.Effect<never[], never, never>;
49
68
  read: (args: {
50
69
  olds: Props;
@@ -65,7 +84,7 @@ export declare const unifiHandlers: <Props extends object, Live, Attributes exte
65
84
  session: {
66
85
  note: (message: string) => Effect.Effect<void, never, never>;
67
86
  };
68
- }) => Effect.Effect<Attributes, E | E2 | import("./policy.ts").UnifiIdentityChanged | import("./policy.ts").UnifiLiveDriftedSinceDeploy | import("./policy.ts").UnifiUpdateDidNotConverge | import("./policy.ts").UnifiWriteRefused, HttpClient.HttpClient>;
87
+ }) => Effect.Effect<Attributes, E | E2 | E3 | import("./policy.ts").UnifiCreateDidNotConverge | import("./policy.ts").UnifiIdentityChanged | import("./policy.ts").UnifiLiveDriftedSinceDeploy | import("./policy.ts").UnifiUpdateDidNotConverge | import("./policy.ts").UnifiWriteRefused, HttpClient.HttpClient>;
69
88
  delete: (args: {
70
89
  olds: Props;
71
90
  }) => Effect.Effect<never, import("./policy.ts").UnifiWriteRefused, HttpClient.HttpClient>;
@@ -1 +1 @@
1
- {"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../../src/unifi/resource.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../../src/unifi/resource.ts"],"names":[],"mappings":"AA0CA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAE3C,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,yCAAyC,CAAC;AACrF,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,UAAU,MAAM,wBAAwB,CAAC;AAE1D,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,WAAW,EAChB,KAAK,WAAW,EAGjB,MAAM,uBAAuB,CAAC;AAG/B,gGAAgG;AAChG,MAAM,MAAM,iBAAiB,GAAG,UAAU,CAAC,UAAU,CAAC;AAEtD;;;;;GAKG;AACH,MAAM,MAAM,SAAS,CACnB,KAAK,SAAS,MAAM,EACpB,IAAI,EACJ,UAAU,SAAS,MAAM,EACzB,CAAC,EACD,EAAE,GAAG,KAAK,EACV,EAAE,GAAG,KAAK,IACR;IACF,mFAAmF;IACnF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,6FAA6F;IAC7F,QAAQ,CAAC,SAAS,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,GAAG,SAAS,EAAE,CAAC,EAAE,qBAAqB,CAAC,CAAC;IAChG,QAAQ,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,KAAK,UAAU,CAAC;IAC9D,QAAQ,CAAC,OAAO,EAAE,CAAC,UAAU,EAAE,UAAU,EAAE,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC;IACpE,sDAAsD;IACtD,QAAQ,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,MAAM,CAAC;IAC5C,8FAA8F;IAC9F,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC;IAC/C;qDACiD;IACjD,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC;CAChD,CAAC;AAEF,eAAO,MAAM,eAAe,GAAI,KAAK,SAAS,MAAM,EAAE,IAAI,EAAE,UAAU,SAAS,MAAM,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,QACxF,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC;kBAE9B,KAAK;oCAQQ;QAAE,IAAI,EAAE,KAAK,CAAC;QAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAAA;KAAE;iBAUlE,KAAK,CAAC,KAAK,CAAC,UAAU,UAAU,GAAG,SAAS;yBAIZ,QAAQ;;yBAElC,MAAM;;IAIzB;;;;;;;;;;;;;;;;OAgBG;sBACe,KAAK,SAAQ,OAAO,CAAC,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,CAAC;oBAc1D,KAAK;CAExB,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,aAAa,GAAI,KAAK,SAAS,MAAM,EAAE,IAAI,EAAE,UAAU,SAAS,MAAM,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,QACtF,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC;IAejD,IAAI;IACJ,IAAI,SAAS;QAAE,IAAI,EAAE,KAAK,CAAC;QAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAAA;KAAE;IAE5D,IAAI,SAAS;QAAE,IAAI,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;QAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAAA;KAAE;yBAlEtB,QAAQ;;yBAElC,MAAM;;IAmEzB,SAAS,SAAS;QAChB,IAAI,EAAE,KAAK,CAAC;QACZ,IAAI,EAAE,KAAK,GAAG,SAAS,CAAC;QACxB,MAAM,EAAE,UAAU,GAAG,SAAS,CAAC;QAC/B,OAAO,EAAE;YAAE,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,KAAK,CAAC,CAAA;SAAE,CAAC;KAC3E;IAqBD,MAAM,SAAS;QAAE,IAAI,EAAE,KAAK,CAAA;KAAE;CAEjC,CAAC"}
@@ -47,7 +47,7 @@ export declare const handlers: {
47
47
  session: {
48
48
  note: (message: string) => Effect.Effect<void, never, never>;
49
49
  };
50
- }) => Effect.Effect<TrafficMatchingListAttributes, import("./policy.ts").UnifiIdentityChanged | import("./policy.ts").UnifiLiveDriftedSinceDeploy | import("./policy.ts").UnifiUpdateDidNotConverge | import("./policy.ts").UnifiWriteRefused | trafficMatchingLists.UnifiNetworkOpError, import("effect/http/HttpClient").HttpClient>;
50
+ }) => Effect.Effect<TrafficMatchingListAttributes, import("./policy.ts").UnifiCreateDidNotConverge | import("./policy.ts").UnifiIdentityChanged | import("./policy.ts").UnifiLiveDriftedSinceDeploy | import("./policy.ts").UnifiUpdateDidNotConverge | import("./policy.ts").UnifiWriteRefused | trafficMatchingLists.UnifiNetworkOpError, import("effect/http/HttpClient").HttpClient>;
51
51
  delete: (args: {
52
52
  olds: TrafficMatchingListProps;
53
53
  }) => Effect.Effect<never, import("./policy.ts").UnifiWriteRefused, import("effect/http/HttpClient").HttpClient>;
@@ -1,7 +1,7 @@
1
1
  import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Protocol';
2
2
  import * as Effect from 'effect/Effect';
3
3
  import type { FieldDrift } from './drift.ts';
4
- import { UnifiIdentityChanged, UnifiLiveDriftedSinceDeploy, UnifiUpdateDidNotConverge } from './policy.ts';
4
+ import { UnifiCreateDidNotConverge, UnifiIdentityChanged, UnifiLiveDriftedSinceDeploy, UnifiUpdateDidNotConverge } from './policy.ts';
5
5
  import type { UnifiSpec } from './resource.ts';
6
6
  import type { AllowedWrite } from './wire-guard.ts';
7
7
  export type Patch<Props> = {
@@ -27,5 +27,39 @@ export type ReconcileArgs<Props extends object> = {
27
27
  readonly output: unknown;
28
28
  readonly note: (message: string) => Effect.Effect<void>;
29
29
  };
30
- export declare const updateReconcile: <Props extends object, Live, Attributes extends object, E, E2>(spec: UnifiSpec<Props, Live, Attributes, E, E2>, { news, olds, output, note }: ReconcileArgs<Props>) => Effect.Effect<Attributes, E | E2 | UnifiIdentityChanged | UnifiLiveDriftedSinceDeploy | UnifiUpdateDidNotConverge | import("./policy.ts").UnifiWriteRefused, UnifiNetworkOpContext>;
30
+ export declare const updateReconcile: <Props extends object, Live, Attributes extends object, E, E2, E3>(spec: UnifiSpec<Props, Live, Attributes, E, E2, E3>, { news, olds, output, note }: ReconcileArgs<Props>) => Effect.Effect<Attributes, E | E2 | UnifiIdentityChanged | UnifiLiveDriftedSinceDeploy | UnifiUpdateDidNotConverge | import("./policy.ts").UnifiWriteRefused, UnifiNetworkOpContext>;
31
+ /**
32
+ * The write half of a CREATE-capable family: today, only `Unifi.FirewallPolicy`, and only for an
33
+ * `hf:`-named row (`policy.ts`'s `isHfPolicyName`). Deliberately NOT a merge-with-live update —
34
+ * there is no live object yet — so there is no `patchKeys`/`checkScope(live, patch, …)` here: a
35
+ * create sends exactly the fields the declaration owns, nothing echoed from elsewhere, nothing to
36
+ * accidentally reset.
37
+ *
38
+ * ⛔ `checkScope` RUNS BEFORE THE POST (same ordering `update.checkScope` already has): a
39
+ * non-`hf:` name is refused here, typed, never reaching the wire at all.
40
+ * ★ `identify` CLOSES THE LOOP FROM THE POST'S RESPONSE TO A FRESH GET. The vendor assigns the id
41
+ * on create; `write`'s response carries it, but a response body is not proof the object is
42
+ * actually readable back (`createReconcile` re-fetches with the id `identify` folds in, rather
43
+ * than trusting the POST's own echo — same reasoning `network-update.ts`'s header gives for
44
+ * never trusting a write's own response over a real read).
45
+ */
46
+ export type UnifiCreate<Props extends object, Live, E3> = {
47
+ /** The one POST the wire guard may pass for this family (the site's fixed collection route,
48
+ * built from `props` the same way `UnifiUpdate.allowedWrite` reads a row's ids from `props`). */
49
+ readonly allowedWrite: (props: Props) => AllowedWrite;
50
+ /** Refuses (typed, before any note or request) a create outside this family's write scope. */
51
+ readonly checkScope: (props: Props) => Effect.Effect<void, E3>;
52
+ readonly write: (props: Props) => Effect.Effect<Live, E3, UnifiNetworkOpContext>;
53
+ /** `props` plus the vendor id the POST's response assigned, for the read-back `fetchLive`. */
54
+ readonly identify: (props: Props, written: Live) => Props;
55
+ readonly driftOf: (live: Live, props: Props) => ReadonlyArray<FieldDrift>;
56
+ };
57
+ /**
58
+ * CREATE for a family whose live read came back absent AND which has no prior Alchemy state
59
+ * (`resource.ts`'s `unifiOperations.reconcile` is the only caller, and only under that same
60
+ * condition — see its own header). Refuses (untyped-to-typed, same `refuseWrite` every other
61
+ * refusal uses) when the family declares no `create` at all; `create.checkScope` then refuses
62
+ * again, more specifically, for a declared row the create scope itself does not allow.
63
+ */
64
+ export declare const createReconcile: <Props extends object, Live, Attributes extends object, E, E2, E3>(spec: UnifiSpec<Props, Live, Attributes, E, E2, E3>, news: Props, note: (message: string) => Effect.Effect<void, never, never>) => Effect.Effect<Attributes, E | E3 | UnifiCreateDidNotConverge | import("./policy.ts").UnifiWriteRefused, UnifiNetworkOpContext>;
31
65
  //# sourceMappingURL=update-reconcile.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"update-reconcile.d.ts","sourceRoot":"","sources":["../../src/unifi/update-reconcile.ts"],"names":[],"mappings":"AA2BA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,yCAAyC,CAAC;AACrF,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC7C,OAAO,EACL,oBAAoB,EACpB,2BAA2B,EAC3B,yBAAyB,EAE1B,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAEpD,MAAM,MAAM,KAAK,CAAC,KAAK,IAAI;IAAE,GAAG,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC;IAAC,KAAK,EAAE,aAAa,CAAC,MAAM,KAAK,CAAC,CAAA;CAAE,CAAC;AAEtF,mGAAmG;AACnG,MAAM,MAAM,WAAW,CAAC,KAAK,SAAS,MAAM,EAAE,IAAI,EAAE,EAAE,IAAI;IACxD,2FAA2F;IAC3F,QAAQ,CAAC,YAAY,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,YAAY,CAAC;IACtD,gDAAgD;IAChD,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC,MAAM,KAAK,CAAC,CAAC;IAC/C,QAAQ,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,KAAK,aAAa,CAAC,UAAU,CAAC,CAAC;IAC1E,6FAA6F;IAC7F,QAAQ,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,KAAK,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;IAChG,QAAQ,CAAC,KAAK,EAAE,CACd,IAAI,EAAE,IAAI,EACV,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,EACnB,KAAK,EAAE,KAAK,KACT,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,EAAE,qBAAqB,CAAC,CAAC;CACrD,CAAC;AAEF,gGAAgG;AAChG,eAAO,MAAM,YAAY,GAAI,KAAK,SAAS,MAAM,QACzC,KAAK,QACL,KAAK,aACA,aAAa,CAAC,MAAM,KAAK,CAAC,KACpC,KAAK,CAAC,KAAK,CASb,CAAC;AAEF,MAAM,MAAM,aAAa,CAAC,KAAK,SAAS,MAAM,IAAI;IAChD,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,KAAK,GAAG,SAAS,CAAC;IACjC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;CACzD,CAAC;AAEF,eAAO,MAAM,eAAe,GAAI,KAAK,SAAS,MAAM,EAAE,IAAI,EAAE,UAAU,SAAS,MAAM,EAAE,CAAC,EAAE,EAAE,QACpF,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,EAAE,EAAE,CAAC,gCACjB,aAAa,CAAC,KAAK,CAAC,wLA2ChD,CAAC"}
1
+ {"version":3,"file":"update-reconcile.d.ts","sourceRoot":"","sources":["../../src/unifi/update-reconcile.ts"],"names":[],"mappings":"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"}
@@ -69,7 +69,7 @@ export declare const handlers: {
69
69
  session: {
70
70
  note: (message: string) => Effect.Effect<void, never, never>;
71
71
  };
72
- }) => Effect.Effect<WifiBroadcastAttributes, import("./policy.ts").UnifiIdentityChanged | import("./policy.ts").UnifiLiveDriftedSinceDeploy | UnifiPaginationInconsistent | import("./policy.ts").UnifiUpdateDidNotConverge | import("./policy.ts").UnifiWriteRefused | wifiBroadcasts.UnifiNetworkOpError, import("effect/http/HttpClient").HttpClient>;
72
+ }) => Effect.Effect<WifiBroadcastAttributes, import("./policy.ts").UnifiCreateDidNotConverge | import("./policy.ts").UnifiIdentityChanged | import("./policy.ts").UnifiLiveDriftedSinceDeploy | UnifiPaginationInconsistent | import("./policy.ts").UnifiUpdateDidNotConverge | import("./policy.ts").UnifiWriteRefused | wifiBroadcasts.UnifiNetworkOpError, import("effect/http/HttpClient").HttpClient>;
73
73
  delete: (args: {
74
74
  olds: WifiBroadcastProps;
75
75
  }) => Effect.Effect<never, import("./policy.ts").UnifiWriteRefused, import("effect/http/HttpClient").HttpClient>;
@@ -5,7 +5,7 @@ import * as HttpClient from 'effect/http/HttpClient';
5
5
  * `baseUrl + tail` and requires an empty query and fragment (`isAllowed`).
6
6
  */
7
7
  export type AllowedWrite = {
8
- readonly method: 'PUT';
8
+ readonly method: 'PUT' | 'POST';
9
9
  readonly tail: string;
10
10
  };
11
11
  export declare class UnifiRefusedRequest extends Error {
@@ -1 +1 @@
1
- {"version":3,"file":"wire-guard.d.ts","sourceRoot":"","sources":["../../src/unifi/wire-guard.ts"],"names":[],"mappings":"AAkCA,OAAO,KAAK,UAAU,MAAM,wBAAwB,CAAC;AAKrD;;;;GAIG;AACH,MAAM,MAAM,YAAY,GAAG;IAAE,QAAQ,CAAC,MAAM,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAE7E,qBAAa,mBAAoB,SAAQ,KAAK;IAE1C,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,+DAA+D;IAC/D,QAAQ,CAAC,IAAI,EAAE,MAAM;IAHvB,YACW,MAAM,EAAE,MAAM;IACvB,+DAA+D;IACtD,IAAI,EAAE,MAAM,EACrB,MAAM,SAAiC,EASxC;CACF;AAED,iGAAiG;AACjG,eAAO,MAAM,kBAAkB,4BAAsB,CAAC;AACtD,MAAM,MAAM,kBAAkB,GAAG,mBAAmB,CAAC;AA0FrD;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,UAAW,aAAa,CAAC,YAAY,CAAC,YAAY,MAAM,sFASrF,CAAC;AAkBF;;;GAGG;AACH,eAAO,MAAM,iBAAiB,mFAAwB,CAAC"}
1
+ {"version":3,"file":"wire-guard.d.ts","sourceRoot":"","sources":["../../src/unifi/wire-guard.ts"],"names":[],"mappings":"AAyCA,OAAO,KAAK,UAAU,MAAM,wBAAwB,CAAC;AAKrD;;;;GAIG;AACH,MAAM,MAAM,YAAY,GAAG;IAAE,QAAQ,CAAC,MAAM,EAAE,KAAK,GAAG,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtF,qBAAa,mBAAoB,SAAQ,KAAK;IAE1C,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,+DAA+D;IAC/D,QAAQ,CAAC,IAAI,EAAE,MAAM;IAHvB,YACW,MAAM,EAAE,MAAM;IACvB,+DAA+D;IACtD,IAAI,EAAE,MAAM,EACrB,MAAM,SAAiC,EASxC;CACF;AAED,iGAAiG;AACjG,eAAO,MAAM,kBAAkB,4BAAsB,CAAC;AACtD,MAAM,MAAM,kBAAkB,GAAG,mBAAmB,CAAC;AA0FrD;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,UAAW,aAAa,CAAC,YAAY,CAAC,YAAY,MAAM,sFASrF,CAAC;AAkBF;;;GAGG;AACH,eAAO,MAAM,iBAAiB,mFAAwB,CAAC"}
@@ -122,8 +122,10 @@ Two checks, both real, neither skippable:
122
122
  No special-casing: add a changeset (`bun changeset`, minor — new package),
123
123
  push, merge. `scripts/publish.ts` packs with `bun pm pack` (resolves
124
124
  `workspace:`/`catalog:` the way `npm pack` cannot) and publishes with `npm
125
- publish --provenance` from CI. `README.md`/`LICENSE`/`scripts/smoke.ts` ship
126
- in `files`; nothing about the release pipeline needs to know this package is
125
+ publish --provenance` from CI. `README.md`/`LICENSE` ship in `files`;
126
+ `scripts/smoke.ts` is outside `files` so it never packs — `scripts/pack.ts`
127
+ additionally strips the `scripts`/`devDependencies` manifest fields at pack
128
+ time. Nothing about the release pipeline needs to know this package is
127
129
  an interim stand-in.
128
130
 
129
131
  ### 5. Alias it in, then cut over later
@@ -0,0 +1,27 @@
1
+ # The 2026-09-23 adversarial review of the linux sudo runner
2
+
3
+ Extracted from [linux-sudo.md](./linux-sudo.md) to keep that doc under the 200-line
4
+ cap. The findings it records are live rules of the runner, not history.
5
+
6
+ 🔴 **Adversarial review, 2026-09-23, two rounds — everything found is fixed
7
+ and tested.** Round 1: a directory this runner elevates was checked for
8
+ SHAPE only, never mode or owner. `mkdir -m 0777` and `chown <non-root uid>`
9
+ both passed untouched, so a stack could declare `HostDirectory({ path:
10
+ '/etc/systemd/system/x.service.d', mode: 0o777 })`, drop an `ExecStart=`
11
+ override into it, and this runner's own next `daemon-reload` + `restart`
12
+ would run it as root. Fixed: a directory this runner elevates is now always
13
+ root-owned and never setuid/setgid or group/other-writable, unconditionally
14
+ — a directory's OWNER always has write access through the owner bits alone,
15
+ so no mode restriction alone could make a non-root-owned directory safe
16
+ here. The same round tightened the shared `modeProblem` check
17
+ (`../launchd/sudo-guard.ts`, used by both platforms): it exempted a file
18
+ merely because its `uid` was non-root, missing that `{uid: 501, gid: 0,
19
+ mode: 0o2775}` — setgid to root's own group — reaches the same escalation
20
+ through the group instead. Round 2 found the round-1 `modeProblem` fix was
21
+ itself incomplete (an OMITTED `gid`, not just an explicit `0`, defaults to
22
+ root's own group under `install` — see the section above), the masked-unit
23
+ and FragmentPath-scope gaps documented above, `install`'s own numeric-id
24
+ ambiguity (also above), and a swallowed cleanup-`rm` failure (now included
25
+ in the thrown error, with the leftover path named). All covered by tests:
26
+ `sudo-allowlist.test.ts`, `sudo-lifecycle.test.ts`, `sudo-runner.test.ts`,
27
+ `../launchd/sudo-modes.test.ts`.
@@ -60,18 +60,19 @@ staging directory is removed either way.
60
60
 
61
61
  Each is `/usr/bin/sudo -n -- <argv>`, every program by absolute path.
62
62
 
63
- | when | argv |
64
- | ------------------------------------ | ----------------------------------------------------------------------------------- |
65
- | a file under a prefix — stage→temp | `/usr/bin/install -m <0644> -T -- <staged> <temp>` |
66
- | its owner, if not root:root | `/usr/bin/chown +<uid>[:+<gid>] -- <temp>` — numeric, `+`-forced, bound to `<temp>` |
67
- | a file under a prefix — temp→dest | `/usr/bin/mv -f -T -- <temp> <dest>` |
68
- | a file under a prefix is removed | `/usr/bin/rm -f -- <path>` |
69
- | a directory under a prefix is made | `/usr/bin/mkdir -m <octal> -- <path>` — never setuid/setgid or group/other-write |
70
- | its mode is fixed | `/usr/bin/chmod <octal> -- <path>` — same restriction |
71
- | its owner is fixed | `/usr/bin/chown <owner> -- <path>` — **root only**: `+0`, `:+0` or `+0:+0` |
72
- | it is removed | `/usr/bin/rmdir -- <path>` |
73
- | after any unit-file write/removal | `/usr/bin/systemctl daemon-reload` |
74
- | a unit is enabled/disabled/started/… | `/usr/bin/systemctl <verb> -- <unit>` |
63
+ | when | argv |
64
+ | ------------------------------------ | --------------------------------------------------------------------------------------------- |
65
+ | a file under a prefix — stage→temp | `/usr/bin/install -m <0644> -T -- <staged> <temp>` |
66
+ | its owner, if not root:root | `/usr/bin/chown +<uid>[:+<gid>] -- <temp>` — numeric, `+`-forced, bound to `<temp>` |
67
+ | a file under a prefix — temp→dest | `/usr/bin/mv -f -T -- <temp> <dest>` |
68
+ | a file under a prefix is removed | `/usr/bin/rm -f -- <path>` |
69
+ | a directory under a prefix is made | `/usr/bin/mkdir -m <octal> -- <path>` — never setuid/setgid or group/other-write |
70
+ | its mode is fixed | `/usr/bin/chmod <octal> -- <path>` — same restriction |
71
+ | its owner is fixed | `/usr/bin/chown <owner> -- <path>` — **root only**: `+0`, `:+0` or `+0:+0` |
72
+ | it is removed | `/usr/bin/rmdir -- <path>` |
73
+ | after any unit-file write/removal | `/usr/bin/systemctl daemon-reload` |
74
+ | a unit is enabled/disabled/started/… | `/usr/bin/systemctl <verb> -- <unit>` |
75
+ | a `privilegedReads` file is read | `/usr/bin/base64 -- <path>` — exact listed path, root-owned regular file, or the read refuses |
75
76
 
76
77
  `enable`/`disable`/`start`/`stop`/`restart` elevate only when the unit is not
77
78
  **masked** (`LoadState`, read alongside `FragmentPath` in the same call —
@@ -103,7 +104,7 @@ know that syntax exists.
103
104
 
104
105
  ## What stays as the operator
105
106
 
106
- - Every read: `stat`, `readFile`, user/group lookups, `systemctl show`.
107
+ - Every read: `stat`, `readFile`, user/group lookups, `systemctl show` — with one exact exception: a file on the runner's `privilegedReads` list is read with `sudo -n base64` (the plan-diff read a declared root-owned file needs; see the table above). Every other path, and every `checkWrite` host probe (`id -G`, `ls`, `stat`), stays the operator's own.
107
108
  - `checkWrite` — the plan-time half of a write, run at every plan so a
108
109
  refusal surfaces before any resource is applied, never at the write.
109
110
  - Files and directories outside every prefix (a foreign owner there is a
@@ -111,10 +112,11 @@ know that syntax exists.
111
112
  - `ls -ldn` and `mktemp -d` — the guard's chain read and the write's own
112
113
  staging, both unprivileged (`src/linux/sudo-listing.ts`, `sudo-stage.ts`).
113
114
 
114
- ★ **So a plan never calls sudo**, and construction never does either:
115
- `sshSudoRunner()` runs one combined, read-only `test -x` for the nine
116
- absolute programs above, so a missing coreutils package or a systemd-free
117
- container fails with a clear message at construction — never a sudo probe.
115
+ ★ **So a plan calls sudo for exactly one thing** — reading a `privilegedReads`
116
+ file — and construction never does: `sshSudoRunner()` runs one combined,
117
+ read-only `test -x` for the nine absolute programs above, so a missing
118
+ coreutils package or a systemd-free container fails with a clear message at
119
+ construction — never a sudo probe.
118
120
 
119
121
  ## The host guard
120
122
 
@@ -144,31 +146,17 @@ prefix-sibling (`/usr/local/bin-x` is not under `/usr/local/bin`), or a
144
146
  `chown` by name, a bare digit, or any non-root id; `systemctl mask`/`--user`;
145
147
  a masked unit, any verb; `enable`/`start`/`restart` on a unit with no unit
146
148
  file; a root-owned file that would be group- or world-writable, setuid or
147
- setgid; a file the operator could not read back (both `Systemd.Unit` and
148
- `Remote.File` read every write back to compare digests).
149
+ setgid; a file NOT on the `privilegedReads` list that the operator could not
150
+ read back (both `Systemd.Unit` and `Remote.File` read every write back to
151
+ compare digests); a listed file handed to a non-root uid, whatever its mode
152
+ — the elevated read-back refuses anything but a root-owned regular file, so
153
+ the handoff would land and then fail every read.
149
154
 
150
155
  🔴 **Adversarial review, 2026-09-23, two rounds — everything found is fixed
151
- and tested.** Round 1: a directory this runner elevates was checked for
152
- SHAPE only, never mode or owner. `mkdir -m 0777` and `chown <non-root uid>`
153
- both passed untouched, so a stack could declare `HostDirectory({ path:
154
- '/etc/systemd/system/x.service.d', mode: 0o777 })`, drop an `ExecStart=`
155
- override into it, and this runner's own next `daemon-reload` + `restart`
156
- would run it as root. Fixed: a directory this runner elevates is now always
157
- root-owned and never setuid/setgid or group/other-writable, unconditionally
158
- — a directory's OWNER always has write access through the owner bits alone,
159
- so no mode restriction alone could make a non-root-owned directory safe
160
- here. The same round tightened the shared `modeProblem` check
161
- (`../launchd/sudo-guard.ts`, used by both platforms): it exempted a file
162
- merely because its `uid` was non-root, missing that `{uid: 501, gid: 0,
163
- mode: 0o2775}` — setgid to root's own group — reaches the same escalation
164
- through the group instead. Round 2 found the round-1 `modeProblem` fix was
165
- itself incomplete (an OMITTED `gid`, not just an explicit `0`, defaults to
166
- root's own group under `install` — see the section above), the masked-unit
167
- and FragmentPath-scope gaps documented above, `install`'s own numeric-id
168
- ambiguity (also above), and a swallowed cleanup-`rm` failure (now included
169
- in the thrown error, with the leftover path named). All covered by tests:
170
- `sudo-allowlist.test.ts`, `sudo-lifecycle.test.ts`, `sudo-runner.test.ts`,
171
- `../launchd/sudo-modes.test.ts`.
156
+ and tested** — the full record (the directory shape-only gap, the shared `modeProblem`
157
+ escapes, the omitted-`gid` default, the masked-unit and `FragmentPath` scope gaps,
158
+ `install`'s numeric-id ambiguity, the swallowed cleanup failure) lives in
159
+ [linux-sudo-review-2026-09-23.md](./linux-sudo-review-2026-09-23.md).
172
160
 
173
161
  Every refusal throws `SudoRefusedError`: nothing ran as root. A privileged
174
162
  command that ran and failed throws a plain `Error` with its exit code.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.55.0",
3
+ "version": "0.57.0",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -72,9 +72,15 @@ export const canRead = (
72
72
  /**
73
73
  * ⛔ REFUSE A FILE THE DEPLOYING USER COULD NOT READ BACK, before writing it. Both providers read
74
74
  * every write back to verify it (host-file-lifecycle.ts, job-lifecycle.ts) and read it again at
75
- * every plan, as the deploying user; this runner elevates writes, never reads. A root-only
76
- * `0600` would be installed, then fail the read-back with EACCES and stay on disk with no state,
77
- * so every later plan would fail too.
75
+ * every plan, as the deploying user; a runner that elevates writes but no reads strands a
76
+ * root-only `0600` on disk — installed, failing the read-back with EACCES, with no state, so
77
+ * every later plan would fail too.
78
+ * ★ `readAsRoot` IS THE ONE EXCEPTION, and it is not this guard's to grant: the caller passes it
79
+ * only when the RUNNER reads this exact path back through a privileged read (the linux
80
+ * sshSudoRunner's `privilegedReads` list names the file, so its readFile goes through
81
+ * `sudo -n base64`). The read-back then cannot fail with EACCES whatever the mode is, and the
82
+ * refusal would be false; every other check — setuid/setgid, group-writable, the path chain —
83
+ * still applies to that file exactly as before.
78
84
  * ★ An omitted owner is root (under a prefix the runner writes as root). An omitted group is the
79
85
  * parent directory's, which is the group a new file gets on macOS.
80
86
  * ⚠️ Unknown groups never refuse: the read-back after the write stays the final judge.
@@ -84,7 +90,9 @@ export const assertReadableBack = async (
84
90
  path: string,
85
91
  options: WriteOptions,
86
92
  groups: readonly number[] | undefined,
93
+ readAsRoot = false,
87
94
  ): Promise<void> => {
95
+ if (readAsRoot) return;
88
96
  const operator = base.effectiveUid();
89
97
  const parent = path.slice(0, path.lastIndexOf('/')) || '/';
90
98
  const gid = options.gid ?? (await base.stat(parent))?.gid ?? 0;
@@ -13,6 +13,7 @@
13
13
  * separate "staged bytes" side-channel to model, unlike the Mac fake's local temp files.
14
14
  */
15
15
  import type { ExecResult, FileStat } from '../launchd/runner.ts';
16
+ import { BASE64 } from './sudo-runner.ts';
16
17
  import { CHMOD, CHOWN, INSTALL, MKDIR, MV, RM, RMDIR, SYSTEMCTL_ABS } from './sudo-allowlist.ts';
17
18
  import type { fakeLinuxHost } from './fake-linux-host.ts';
18
19
 
@@ -113,6 +114,20 @@ const fileRoot = (
113
114
  if (path !== undefined) fake.files.delete(path);
114
115
  return ok();
115
116
  }
117
+ /**
118
+ * ★ `sudo -n base64 -- <path>` — the elevated read itself (sudo-runner.ts's readFile), so a
119
+ * test can join the two halves: a hidden-mode file just written through the skipped guard
120
+ * reads back AS ROOT here, never EACCES. Refuses exactly what the runner's own pre-elevation
121
+ * check refuses: a root-owned REGULAR file, nothing else.
122
+ */
123
+ if (program === BASE64) {
124
+ const [, path] = args;
125
+ const entry = path === undefined ? undefined : fake.files.get(path);
126
+ if (entry === undefined || entry.kind !== 'file' || entry.uid !== 0) {
127
+ return fail(1, `base64: '${String(path)}': No such file or directory`);
128
+ }
129
+ return ok(`${Buffer.from(entry.bytes).toString('base64')}\n`);
130
+ }
116
131
  return undefined;
117
132
  };
118
133
 
@@ -59,6 +59,14 @@ export type FakeSudoHostOptions = {
59
59
  readonly hostFactory?: (options: FakeLinuxOptions) => ReturnType<typeof fakeLinuxHost>;
60
60
  /** The runner's own declared prefixes. @default PREFIXES */
61
61
  readonly prefixes?: readonly string[];
62
+ /** The runner's exact-path elevated reads (`sshSudoRunner`'s `privilegedReads`). */
63
+ readonly privilegedReads?: readonly string[];
64
+ /**
65
+ * What `/usr/bin/id -G` answers (sudo-guard-mode.ts's `operatorGroups`). Unset, the fake has
66
+ * no `id` and `operatorGroups` reads `undefined` — "unknown groups never refuse" — which is
67
+ * what every pre-existing test relied on; set it to make the read-back refusal decidable.
68
+ */
69
+ readonly operatorGroups?: readonly number[];
62
70
  };
63
71
 
64
72
  /**
@@ -69,7 +77,12 @@ export type FakeSudoHostOptions = {
69
77
  export const fakeSudoHost = (
70
78
  extraDirs: Record<string, number> = {},
71
79
  acl: ReadonlySet<string> = new Set(),
72
- { hostFactory = fakeLinuxHost, prefixes = PREFIXES }: FakeSudoHostOptions = {},
80
+ {
81
+ hostFactory = fakeLinuxHost,
82
+ prefixes = PREFIXES,
83
+ privilegedReads,
84
+ operatorGroups: idGroups,
85
+ }: FakeSudoHostOptions = {},
73
86
  ) => {
74
87
  const dirs: Record<string, number> = {};
75
88
  for (const prefix of prefixes) dirs[prefix] = 0;
@@ -131,6 +144,11 @@ export const fakeSudoHost = (
131
144
  return handleMktemp();
132
145
  }
133
146
  if (argv[0] === 'rm' || argv[0] === '/usr/bin/rm') return handleRm(argv);
147
+ // ★ Only modelled when a test asks for it: unset, `id` stays unknown and the read-back
148
+ // guard keeps its "unknown groups never refuse" behavior (see the option's docs).
149
+ if (argv[0] === '/usr/bin/id' && argv[1] === '-G' && idGroups !== undefined) {
150
+ return ok(`${idGroups.join(' ')}\n`);
151
+ }
134
152
  if (argv[0] === '/usr/bin/systemctl')
135
153
  return fake.runner.exec(['systemctl', ...argv.slice(1)]);
136
154
  if (argv[0] !== SUDO) return fake.runner.exec(argv);
@@ -146,6 +164,7 @@ export const fakeSudoHost = (
146
164
  const runner = makeSshSudoRunner(prefixes, {
147
165
  base,
148
166
  log: (line) => logs.push(line),
167
+ ...(privilegedReads === undefined ? {} : { privilegedReads }),
149
168
  // ★ The real stager, unmodified: it only ever calls `base.exec`/`base.writeFileAtomic`, both
150
169
  // of which this fake's `mktemp`/`ls`/operator-level paths already answer.
151
170
  stage: stageRemote(base),
@@ -121,6 +121,15 @@ export const assertGuardedChain = async (
121
121
  * The mode/owner and read-back checks for a file write under `prefix`, plus the path chain.
122
122
  * ★ Reads only, as the operator — the same checks run at plan time (`checkWrite`) and before the
123
123
  * install/mv pair. `expect` is `'file-or-absent'`: a write may create or replace.
124
+ * ★ `privilegedReads` is the runner's exact-path elevated-read set (sudo-runner.ts): a file on it
125
+ * is read back through `sudo -n base64`, so it cannot be stranded by its own mode — every
126
+ * other refusal below applies to it unchanged.
127
+ * ⛔ A LISTED FILE MAY NEVER BE HANDED TO A NON-ROOT UID, whatever its mode: its read back is
128
+ * the elevated one, which refuses anything but a root-owned regular file, so a handoff with
129
+ * operator-readable other bits (0644 uid 1001) would sail through the plain read-back check,
130
+ * land, and die at EVERY later read — the stranding this guard exists to prevent (glm seat
131
+ * round, 2026-10-18). Refused up front instead. An omitted owner is root under a prefix, so
132
+ * plain root writes are untouched.
124
133
  */
125
134
  export const assertInstallable = async (
126
135
  base: HostRunner,
@@ -128,6 +137,7 @@ export const assertInstallable = async (
128
137
  path: string,
129
138
  options: WriteOptions,
130
139
  groups: readonly number[] | undefined,
140
+ privilegedReads?: ReadonlySet<string>,
131
141
  ): Promise<void> => {
132
142
  const mode = modeProblem(options);
133
143
  if (mode !== undefined) {
@@ -137,6 +147,17 @@ export const assertInstallable = async (
137
147
  'real owner. Nothing ran as root.',
138
148
  );
139
149
  }
150
+ const listed = privilegedReads?.has(path) === true;
151
+ const uid = options.uid ?? 0;
152
+ if (listed && uid !== 0) {
153
+ throw refuse(
154
+ path,
155
+ `is on the runner's privilegedReads list, so its read back is the elevated one — which ` +
156
+ `refuses anything but a root-owned regular file. Handing it to uid ${String(uid)} would ` +
157
+ 'land and then fail every read. Keep it root-owned, or take it off the list. Nothing ' +
158
+ 'ran as root.',
159
+ );
160
+ }
140
161
  await assertGuardedChain(base, prefix, path, 'file-or-absent');
141
- await assertReadableBack(base, path, options, groups);
162
+ await assertReadableBack(base, path, options, groups, listed && uid === 0);
142
163
  };