yamine 0.20.0 → 0.21.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c48a7ce222b3ffe113f542e81d84aca45270e4c3ea4f4f7da74bd22e8d0747d7
4
- data.tar.gz: 8a8c1ad0f17b8bd741723cb9d83100248c559f5c7c995d64f30abfcebfe4fa60
3
+ metadata.gz: 73d2fe319b2c8af2c4bf821285dc76af42705e532761a8b2351377696928a114
4
+ data.tar.gz: 5a92b4760142642deed0a5a0131536094ac553b79759924daa7cc2157dec90a6
5
5
  SHA512:
6
- metadata.gz: c9c3ae18ac29e5a30b378d7b70c7dd6c538b9140b058f4308ee2d9e5075567cde9ea21d169d30c54808d6a06a4283d3454d2a652b4b254b40e406d2de4a7edbc
7
- data.tar.gz: 594bf48948e649c390aa3aed20246abd1c0eabbd0889f2a159f3b4a35d77b0d8cf8874b1eb1f8ebb51be9b0dc4300879139b943a35bde12fbc421a789b08f809
6
+ metadata.gz: 859e65d51d8e417cb1c747cf2d95a8336d4266fd57347d611ca3744baf4538dde86190fc5bc46023cbf28a0c12faceb7d2e3f84895afd62617e2c78e7fd1fc46
7
+ data.tar.gz: 8fc48c3d08d4ef3dfb5f579f41583ce3f315c90e896289279b072a5f9e6d66013eaa2b02f66eeb8ce3e983d2d4664287aa3e868e33606bc4f687f7975cd9c25a
data/CHANGELOG.md CHANGED
@@ -2,6 +2,84 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.21.0] — 2026-09-28
6
+
7
+ ### Security
8
+
9
+ - **The privileged service no longer runs user-writable code.** The
10
+ launchd/systemd unit used to execute the gem's `bin`+`lib` straight
11
+ out of the user's home (a version-stamped, user-writable gem
12
+ directory), so anything that could write those paths — a gem upgrade,
13
+ a `bundle install`, an agent editing the gem — ran as root at every
14
+ boot. `service install` now stages `bin`+`lib` into a root-owned
15
+ directory at a stable, version-independent path (`/Library/Application
16
+ Support/yamine` on macOS, `/usr/local/share/yamine` on Linux):
17
+ copied aside, ownership/modes locked, verified fail-closed (a bad
18
+ tree registers no unit and leaves the running daemon untouched), then
19
+ swung live with an atomic symlink swap plus a root-owned `VERSION`
20
+ marker. The unit pins only that path.
21
+ - **The passwordless grant can no longer introduce root-executed
22
+ code.** `yamine sudoers` used to grant the version-stamped gem path
23
+ with `service install --internal` — effectively arbitrary code as
24
+ root for anyone who could write the gem, and stale after every
25
+ release. It now grants only the staged, version-independent payload:
26
+ `hosts sync` (the recurring agent need) and `service uninstall`
27
+ (which only deletes yamine's own files). Staging new root code stays
28
+ a human interactive sudo; a non-interactive `service install` fails
29
+ fast saying so. If your `/etc/sudoers.d/yamine` still pins a
30
+ version-stamped gem path, re-run `yamine sudoers` and replace it.
31
+ - **`hosts sync` is strictly validated before anything is written.**
32
+ Route names are user-writable and the sync runs as root, so every
33
+ name must now be well-formed (no newline/breakout), written only
34
+ inside yamine's managed block, and under `.localhost` or a domain in
35
+ the root-owned staged allowlist. Anything else fails closed naming
36
+ the policy. Custom `proxy.host` domains need one human step: append
37
+ the parent domain to the staged `allowed-tlds` file.
38
+ - **`yamine doctor` gains a `service` check.** It names a legacy unit
39
+ (payload in a user home or version-stamped gem path) with the exact
40
+ migration (`sudo yamine service install`), warns on staged-vs-CLI
41
+ version skew, fails on any root-executed path that is not root-owned
42
+ or is group/other-writable, and always reports the service
43
+ interpreter's path and writability. The legacy daemon keeps working
44
+ until the next elevated install.
45
+
46
+ ### Changed
47
+
48
+ - `service install` (human, interactive, elevated) migrates a legacy
49
+ install in place and prints the interpreter caveat; `service
50
+ uninstall` removes the staged payload as well as the unit, scoped to
51
+ yamine's own files.
52
+ - Non-interactive (`sudo -n`) failure messaging points at the new
53
+ provisioning story: humans install the 443 service once per machine,
54
+ agents keep hosts entries fresh through the grant.
55
+
56
+ ### Fixed
57
+
58
+ - **The printed grant now matches what the CLI invokes, byte for
59
+ byte.** Elevated re-execs went through `env YAMINE_STATE_DIR=…`,
60
+ which made the command `/usr/bin/env` — a path no rule names — so
61
+ every passwordless attempt fell through to the password-required
62
+ admin rule; and the staged macOS path's space was unescaped in the
63
+ sudoers output, so that rule could never match either. No
64
+ environment crosses sudo anymore (no `env` prefix, no SETENV): the
65
+ root half derives the invoking user's state dir from `SUDO_USER`,
66
+ and spaces are backslash-escaped when the rules are printed. Pinned
67
+ by a parity test that parses the printed specs and asserts equality
68
+ with the captured elevated argv.
69
+ - **The `--no-service` sudo daemon no longer executes the gem
70
+ directory as root.** A privileged `proxy start` spawns the staged
71
+ root-owned payload, staging it first under the same
72
+ human-authorized sudo (`service stage --internal`, never granted)
73
+ when missing. The unprivileged spawn path is unchanged.
74
+
75
+ ### Open caveat (not closed, stated plainly)
76
+ - **The interpreter is still user-writable.** No root-owned Ruby ≥ 3.2
77
+ exists on a stock machine, so the daemon necessarily runs the
78
+ invoking (user-writable) Ruby. The payload is root-owned; the
79
+ interpreter is not. Doctor reports it truthfully and the README says
80
+ so. Fixed only by a root-owned Ruby new enough for the gem; yamine
81
+ will not vendor or stage an interpreter.
82
+
5
83
  ## [0.20.0] — 2026-09-28
6
84
 
7
85
  ### Added
data/README.md CHANGED
@@ -54,21 +54,35 @@ to get back to 443.
54
54
 
55
55
  Binding 443 is privileged, so yamine installs a **root-owned launchd
56
56
  service** (macOS) or systemd unit (Linux) that binds 443 at boot — the
57
- same model as puma-dev and portless. Installing it needs sudo **once per
58
- machine**; after that, every `yamine` run in any project gets a clean
59
- `https://<app>.localhost` with no elevation and no prompt.
57
+ same model as puma-dev and portless. Installing it needs an interactive
58
+ sudo **once per machine** (one Touch ID tap); after that, every `yamine`
59
+ run in any project gets a clean `https://<app>.localhost` with no
60
+ elevation and no prompt.
60
61
 
61
- **Human (interactive):** run setup once — it trusts the CA, installs the
62
- service, syncs hosts, and verifies:
62
+ **Human (interactive):** run setup once — it stages a root-owned copy of
63
+ yamine, installs the service, trusts the CA, syncs hosts, and verifies:
63
64
 
64
65
  ```bash
65
66
  yamine setup
67
+ # or, for just the service: sudo yamine service install
66
68
  ```
67
69
 
68
- **Agent / CI (no TTY):** the same commands fail fast with guidance,
69
- because sudo needs a terminal. To pre-provision a machine or image so
70
- agents can install the service without a prompt, install the scoped
71
- passwordless-sudo rules once (as an admin):
70
+ The unit runs a **staged payload**, not your gem directory: the install
71
+ copies yamine's `bin`+`lib` into a root-owned directory at a stable,
72
+ version-independent path (`/Library/Application Support/yamine` on
73
+ macOS, `/usr/local/share/yamine` on Linux), verifies root ownership and
74
+ modes fail-closed (a bad tree registers nothing), and pins that path in
75
+ the unit. Ordinary user-space writes — a gem upgrade, a `bundle
76
+ install`, an agent editing the gem — can no longer change what runs as
77
+ root, and upgrades never stale the setup. `yamine doctor` names a legacy
78
+ install (a unit still pointing at a user-writable gem path) with the
79
+ exact migration, plus staged-vs-CLI version skew and any ownership
80
+ problem.
81
+
82
+ **Agents: the steady-state grant.** New project ⇒ new hostname ⇒ Safari
83
+ needs the `/etc/hosts` entry; that recurring privileged need stays
84
+ agent-invocable without a prompt. Install the scoped passwordless-sudo
85
+ rules once (as an admin):
72
86
 
73
87
  ```bash
74
88
  yamine sudoers > /tmp/yamine.sudoers
@@ -76,11 +90,41 @@ sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine
76
90
  sudo install -o root -g root -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine # Linux
77
91
  ```
78
92
 
79
- `yamine sudoers` prints rules scoped to yamine's own service
80
- re-exec — the gem's exact ruby + bin path with the `service install
81
- --internal` / `service uninstall --internal` subcommands — never a bare
82
- interpreter. Re-run it after upgrading the gem if the install path
83
- changes. To undo: `sudo rm /etc/sudoers.d/yamine`.
93
+ `yamine sudoers` prints rules for the **staged payload only** — `hosts
94
+ sync` (hostnames strictly validated: well-formed, written only inside
95
+ yamine's managed block, and only under `.localhost` or an allowlisted
96
+ domain) and `service uninstall` (which only deletes yamine's own
97
+ files). Installing or upgrading the staged payload moves user-writable
98
+ source into root-owned paths, so it deliberately stays a human
99
+ interactive sudo — no grant will ever cover it, and a non-interactive
100
+ `service install` fails fast saying so. To undo the grant:
101
+ `sudo rm /etc/sudoers.d/yamine`.
102
+
103
+ If you installed an older yamine, your `/etc/sudoers.d/yamine` may still
104
+ pin a version-stamped gem path (`.../gems/yamine-X.Y.Z/...`) including
105
+ an install rule — re-run `yamine sudoers` and replace the file. The old
106
+ rules go stale every release (which pushed people toward `NOPASSWD:
107
+ ALL`); the new ones survive upgrades.
108
+
109
+ **Custom domains** (e.g. `proxy.host: myapp.local.example.com` for OAuth
110
+ parity) need their parent domain allowlisted once by a human — `hosts
111
+ sync` refuses anything outside `.localhost` and the staged allowlist,
112
+ because an unvalidated root hosts write could point a real vendor domain
113
+ at loopback:
114
+
115
+ ```bash
116
+ echo 'local.example.com' | sudo tee -a "/Library/Application Support/yamine/allowed-tlds" # macOS
117
+ echo 'local.example.com' | sudo tee -a /usr/local/share/yamine/allowed-tlds # Linux
118
+ ```
119
+
120
+ **Open caveat: the interpreter.** The unit runs the invoking Ruby, and
121
+ there is no root-owned Ruby ≥ 3.2 on a stock machine (`/usr/bin/ruby` is
122
+ 2.6), so the daemon necessarily runs a user-writable interpreter today.
123
+ The payload is root-owned; the interpreter is not — `yamine doctor`
124
+ reports its path and writability truthfully. Anything that can write
125
+ that Ruby can change what runs as root. This hole stays open until the
126
+ machine has a root-owned Ruby new enough for the gem; yamine will not
127
+ vendor or stage an interpreter to pretend otherwise.
84
128
 
85
129
  ## The one-file model
86
130
 
@@ -940,10 +940,11 @@ module Yamine
940
940
  privileged = port < 1024 && !ProxyControl.root?
941
941
  if privileged && !ctx.interactive?
942
942
  $stderr.puts "Error: proxy is not running and port #{port} needs root."
943
- $stderr.puts " Human: run this once — yamine setup"
944
- $stderr.puts " Agent/CI: pre-provision passwordless sudo once —"
945
- $stderr.puts " yamine sudoers > /tmp/yamine.sudoers"
946
- $stderr.puts " sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine"
943
+ $stderr.puts " Human: run this once in a terminal — yamine setup (or: sudo yamine service install)"
944
+ $stderr.puts " Agent/CI: the 443 service is installed by a human once per machine — it cannot be provisioned passwordlessly."
945
+ $stderr.puts " Steady-state hosts sync works via the grant instead:"
946
+ $stderr.puts " yamine sudoers > /tmp/yamine.sudoers"
947
+ $stderr.puts " sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine"
947
948
  $stderr.puts " Or start the proxy by hand: sudo yamine proxy start"
948
949
  exit 1
949
950
  end