From eb4c756a142af3645f13f88c13c86de2c4d9f889 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 20 Oct 2026 17:26:09 -0500 Subject: [PATCH] machine-grant design r2 (#436): credential.mint binding, enrollment as a request, Duckworth B1-B5 and N1-N3, ops-01 host side (mos-1125, mos-1127) --- agents/gizmoduck/work/machine-grant/DESIGN.md | 616 +++++++++++++----- 1 file changed, 439 insertions(+), 177 deletions(-) diff --git a/agents/gizmoduck/work/machine-grant/DESIGN.md b/agents/gizmoduck/work/machine-grant/DESIGN.md index d85697ad4..0447c2466 100644 --- a/agents/gizmoduck/work/machine-grant/DESIGN.md +++ b/agents/gizmoduck/work/machine-grant/DESIGN.md @@ -1,11 +1,38 @@ -# Machine grant path, design draft r1 (Vikunja #436, row 210 follow-up) +# Machine grant path, design draft r2 (Vikunja #436, row 210 follow-up) -Owner: Gizmoduck. Reviewer: Duckworth. Sizing and row assignment: Sage. Design only, no code until Sage -confirms the row and owner (mos-1115). +Owner: Gizmoduck. Reviewer: Duckworth. Sizing and row assignment: Sage. Design only. Code starts when +Duckworth approves the design and row 210 lands (sage-1527). -Sources: Q188 (DECISIONS.md:1642, jason-master-restart 8ecf252), mos-1115, row 210 DESIGN r3 (e021dfa26) and -its approved candidate 455964f23. Code cites are `path:line` at origin/refactor f41729178 unless they say -"210:", which means the row 210 candidate 455964f23 (the same code as the branch head a2fd30390). +Sources: Q188 (DECISIONS.md:1642, jason-master-restart 8ecf252), mos-1115, mos-1120, mos-1125, mos-1127, +sage-1527, Duckworth's r1 review (REVIEW-r1.md, duckworth/reviews d025ef492, corrected at 749080115), +Honker's PRD 1.8 draft (honker/row194-b2 3c30f1036, REQ-TRUST-2 and REQ-GRANT-1 to -4), row 210 DESIGN r3 +(e021dfa26) and its approved candidate 455964f23. Code cites are `path:line` at origin/refactor f41729178 +unless they say "210:", which means the row 210 candidate 455964f23 (the same code as the branch head +a2fd30390). Host facts come from ops-01's read-only check of .53 and .54 at 22:21Z (mos-1127). + +## r2 changes (from r1 f6b6a04b6) + +- Enrollment is a request the chief raises and Jason approves, not a CLI Jason types (mos-1120 point 3). + Section 3 is rewritten. `hosts.enroll` stays, as the verb the executor runs. +- The action is `credential.mint` with a `grant` binding, not a new `host.grant` (sage-1527 Q-S3, Honker's + REQ-GRANT-1). The hard-waits list doesn't change. R2 says why the binding still carries G1 to G10. +- The binding adds a hash of the agent's role definition, because REQ-GRANT-2 voids an approval when the + definition changes. It also carries the full commands list (N2). +- The host side follows ops-01's answer to Q-O1 (mos-1125): lines in a root-owned + `/etc/ssh/mosaic-keys/%u`, a dedicated install user, a forced command that only parses, and a root + writer in this repo that checks everything again (E3). This answers B5. +- A target user with sudo is enrolled by name, and Jason's Approve of the enrollment covers it (E1, E3, + mos-1125's recommendation). Without this no `host.sudo` grant could be installed. +- B1: a grant, a commands change, an enrollment and a revoke take effect in the running bus (X4). +- B2: the executor holds an `#inflight` entry that revoke aborts, and a revoke works before any grant + exists (X5). +- B3: progress events before each step, and a start-time reconcile that undoes to the state before the + decision (X3). +- B4: the tag is a hash, recorded in events, and `list` and `sweep` only touch this business's recorded + tags (X2, step 2). +- N1 to N3 answered in X1, R4 and E4. +- Section 9 adds ops-01's point that a host line has no `command=` (mos-1125). +- Answers recorded: Q-S1 to Q-S4, Q-M1, Q-O1. New questions Q-M2, Q-S5 and Q-O2. ## 0. What Q188 asks for, and what this draft builds @@ -49,8 +76,10 @@ first is useless for Q188's goal without the second. - F4. **Resolving a decision runs nothing.** `decision.resolve` records a `decision_events` row and messages the raiser (`broker.mjs:1752-1828`). An effect happens later, when a verb consumes the decision through `#consumeAuthority`, which refuses `decision-consumed` if an `action.allowed` event other than the raise - already cites it (`broker.mjs:808-831`). The landing path is the only place where stack code reacts to a - resolution, and even there the agent's own `land_work` call does the work (`cli/src/launcher.mjs:555-600`). + already cites it (`broker.mjs:808-831`). `#consumeAuthority` needs the raiser's own agent session: it + checks `raised_by_role` and the raiser's thread against the caller (`broker.mjs:764`, `:786`). The + landing path is the only place where stack code reacts to a resolution, and even there the agent's own + `land_work` call does the work (`cli/src/launcher.mjs:555-600`). - F5. **The human proof.** `mosaic decide` resolves through `human-cli.mjs` (`cli/src/cli.mjs:142-175`, `cli/src/transport.mjs:55-75`). `verifyHuman` is a same-UID cooperative check (`bus/src/human.mjs:50-52`), and `#human` needs `via === 'cli'` and `outsideAgent` (`broker.mjs:375-378`). @@ -62,9 +91,13 @@ first is useless for Q188's goal without the second. - F7. **Row 210's pieces this path reuses:** - the agent key store at `/secrets/agents///host.` (210: `bus/src/credentials.mjs:153-157`) - - `issue`, which writes tmp, fsync, rename and then calls `commit` (210: `credentials.mjs:243-280`) + - `issue`, which writes tmp, fsync, rename, then calls `commit`, then sets memory and clears the revoked + mark (210: `credentials.mjs:243-280`, the clear at `:279`) - `revoke`, which drops memory, aborts the call in flight and unlinks the file (210: - `credentials.mjs:284-298`) + `credentials.mjs:284-298`, the abort at `:289`). It refuses `credential-unknown` for a service with no + loaded reference (210: `credentials.mjs:235-238`). + - the busy map `#inflight`, instance and service to an `AbortController` (210: `credentials.mjs:118`, + `busy` at `:332-334`, used by `host-run.mjs:249`) - pinned `known_hosts` built from the registry's `hostKeys` with `StrictHostKeyChecking=yes` (210: `bus/src/host-run.mjs:36`, `:170`) - F8. **Nothing on refactor generates keys or contacts a host.** No `ssh-keygen` or ssh spawn exists in @@ -74,102 +107,191 @@ first is useless for Q188's goal without the second. (`webui/src/public/bus.js:3-5`). `decision.resolve` is not in `BUS_VERBS` (`webui/src/serve.mjs:62`). Vikunja #435 adds Approve after Q175 sign-in (#412). - F10. **`credential.mint` is already a hard wait with no executor** (`vocabulary.mjs:38`). +- F11. **The running bus never reloads the business file, except the limits.** The broker keeps a clone + made once (210: `broker.mjs:234`), and `host.run` reads `b.hosts` and `b.roles[role].hosts` from it (210: + `broker.mjs:1731-1735`). `Credentials` loads its references once, in the constructor (210: + `bus/src/runtime.mjs:85-98`, `credentials.mjs:125-147`). Only the limits read the file at each ask, and + `instances.set` answers `restart: true` (`business/src/instance-settings.mjs:73`, `:155`). +- F12. **The business file has no room for state on a host or a grant.** A registry entry takes only + `address, port, user, hostKeys` (210: `business/src/hosts.mjs:75`), and a role's grant only `commands` + (210: `hosts.mjs:123`). This design keeps it that way. Host state, tags and progress live in events. ## 2. Shape ``` -agent --message--> chief --decision.raise host.grant--> Jason's inbox +agent --message--> chief --decision.raise credential.mint + grant--> Jason's inbox | mosaic decide (later: Console Approve, #435) | bus child grant executor (on resolve, approve only) - keygen -> install via host installer -> verify -> business file -> key store + keygen -> install via host installer -> verify -> key file + business file + live bus ``` -Revoke runs the other way: memory, then the in-flight call, then the key file, then the host line, then the -business file. +Revoke runs the other way: memory, then the in-flight call, then the key file, then the business file, then +the host line. -## 3. Host enrollment (once per host, human only) +## 3. Host enrollment (once per host, a request like a grant) Installing a key on a host needs something on the host that already trusts the stack. Nothing on refactor holds that trust, so each host gets one enrollment. This is the step that remains for ops-01, once per -host. Per-agent installs no longer need ops-01. +host, and it goes on the ops-01 run sheet in the installer's I3 row (#432, mos-1120). Per-agent installs no +longer need ops-01. -### E1. What Jason enters +A host here is a registry entry, so it has one user (F12). Two users on one machine are two host ids with the +same address, each enrolled on its own. -`mosaic hosts enroll --address A --port P --user U --install-user I`, with the host's public keys on -stdin, one `ssh-ed25519`, `ecdsa-sha2-nistp256` or `ssh-rsa` line each. ops-01 reads them on the host -(`/etc/ssh/ssh_host_*_key.pub`) and hands them to Jason out of band. The pin comes from the host side, never -from a first connection (210 section 4). The checks are 210 D3 layer 2's: the address rules, refusing -loopback and this machine's names, the port range, the user pattern, and the key types. +### E1. The request -The Console form for this (Q188: "Jason enters ... in the WebUI") waits on #412 like Approve does. Until -then the CLI is the only way in. +ops-01 reads the host's public keys on the host (`/etc/ssh/ssh_host_*_key.pub`) and submits them with the +address, port, the target user and whether that user has sudo. How that reaches the chief is Q-M2. The chief +raises a `credential.mint` decision with the binding (section 4, R4): -### E2. What the stack does +``` +grant: { "method": "host-enroll", "host": "", "address": "A", "port": 22, "user": "U", + "sudo": true, "hostKeys": ["ssh-ed25519 AAAA...", ...] } +``` -The bus child, on a human-only `hosts.enroll` verb: +The raise checks are 210 D3 layer 2's: the address rules, refusing loopback and this machine's names, the +port range, the user pattern and the key types. It also refuses a `hostId` that is already enrolled, a user +named `root`, `localadmin` or `mosaic-installer`, and a `sudo` that isn't a boolean. -1. writes the `hosts.` registry entry through the grant writer (section 5, W1), with `hostKeys` set - to the stdin lines -2. generates an installer key pair with `ssh-keygen -t ed25519 -N '' -C mosaic--installer-` - in a 0700 dir under `/secrets/run`, the dir row 210 already sweeps at start +The rendered block shows each host key's SHA256 fingerprint as well as the line, so Jason can compare it with +what ops-01 reads on the host. With `"sudo": true` it adds one line in plain words: "U has sudo on this host. +Approving lets later grants for U be installed, and section 9's first limit applies to U." + +Jason approves with `mosaic decide` until #412 brings the Console inbox. Jason types no host data (mos-1120: +pasting keys he got from ops-01 checks nothing his Approve doesn't). + +The pin still comes from the host side, never from a first connection (210 section 4). E4's check fails +closed on a wrong pin, so a key altered on the way to the chief makes the enrollment fail. It doesn't get +an agent onto the host. + +Not every host can be enrolled yet. jarvis on .53 and melanie on .54 wait for #423 steps 4 and 5 +(mos-1125 point 5). + +### E2. What the executor does + +On Approve the bus child runs `hosts.enroll`, with the same start rules as a grant (section 5, X1): + +1. writes `host.enroll.step {decision, host, step: 'registry'}`, then the `hosts.` registry entry + through the grant writer (W1) with the bound `address, port, user, hostKeys`, and applies it to the + running broker (X4) +2. generates an installer key pair with `ssh-keygen -t ed25519 -N '' -C mosaic-installer-`, where `` + is the hash of section 5's X2 step 2 over (business, `@installer`, hostId), in a 0700 dir under + `/secrets/run`, the dir row 210 already sweeps at start 3. moves the private half to `/secrets/hosts///installer`, mode 0600 in 0700 - dirs, by the same tmp, fsync, rename steps as 210 `issue` -4. prints the installer's `authorized_keys` line for ops-01: + dirs, by the same tmp, fsync, rename steps as 210 `issue`. The path follows row 199 D1's + `secrets/agents//...` (Q-S2, mos-1120, sage-1527) +4. records `host.enrolled {decision, host, registrySha256, installerFingerprint, user, sudo}` and messages + the human and the chief with what ops-01 installs (E3) -``` -from="",restrict,command="/usr/local/sbin/mosaic-keys" ssh-ed25519 AAAA... mosaic--installer- -``` +No private material goes out. A failure at step 2 or 3 drops the registry entry again through the writer and +from the broker, and ends `grant.failed`, as X3 does. A restart after step 1 does the same (X3). -No private material goes out. The secrets dir layout is new. Sage or Mos may prefer -`secrets/mosaic-stack/...`, which is the Console's (`webui/src/sso.mjs:35`). That is question Q-S2. +The host's state lives in events, not in the business file (F12): -### E3. The host side: `mosaic-keys` +- `pending-install`: a `host.enrolled` exists and no later `host.checked` matches it +- `ready`: the latest `host.checked` names the same `registrySha256` and `installerFingerprint` as the + latest `host.enrolled`, and the current registry entry still hashes to that value -One small script in this repo, under `tools/host/mosaic-keys`, reviewed like any code. ops-01 installs it -once per host as root, mode 0755, owned by root. It is the installer key's forced command, and it reads one -request from `SSH_ORIGINAL_COMMAND`: +A hand edit of the registry entry changes its hash, so the host stops being `ready` until the next check. + +### E3. The host side + +ops-01 answered Q-O1 with six changes (mos-1125). The stack side doesn't change. The host side is four +files in this repo under `tools/host/`, each reviewed like code, and Duckworth reviews the writer, the +sudoers line and the sshd drop-in before .53 (mos-1125 point 6): + +| File | Installed as | Does | +|---|---|---| +| `mosaic-keys` | `/usr/local/sbin/mosaic-keys`, root, 0755 | the installer key's forced command. Parses `SSH_ORIGINAL_COMMAND` against fixed patterns and runs `sudo -n /usr/local/sbin/mosaic-keys-write `. Nothing else. | +| `mosaic-keys-write` | `/usr/local/sbin/mosaic-keys-write`, root, 0755 | checks everything again and writes the line | +| `mosaic-keys.sudoers` | `/etc/sudoers.d/mosaic-keys`, root, 0440 | `mosaic-installer ALL=(root) NOPASSWD: /usr/local/sbin/mosaic-keys-write` | +| `mosaic-keys.sshd.conf` | `/etc/ssh/sshd_config.d/-mosaic-keys.conf` | `AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2 /etc/ssh/mosaic-keys/%u` | + +The drop-in keeps both paths the hosts use today. On .53 and .54, `sshd_config` includes +`sshd_config.d/*.conf` at line 12 and no file sets `AuthorizedKeysFile` (mos-1127 point 2). Root never +writes into a user's home (mos-1125 point 1). + +Host config, all root-owned and written by ops-01 from the approved enrollment message: + +- `/etc/mosaic-keys/users`: the users the writer may touch +- `/etc/mosaic-keys/privileged`: the users with sudo that Jason approved by name (E1 `"sudo": true`) +- `/etc/mosaic-keys/from`: the `from=` value. The stack can't choose it. +- `/etc/ssh/mosaic-keys/`: root, 0755. `mosaic-installer`'s own line lives here, in a root-owned + `mosaic-installer` file that ops-01 writes by hand. + +`mosaic-installer` is a dedicated user, never `localadmin` and never an agent user (mos-1125 point 2). It +needs no entry in any sshd list, because .53 and .54 have no `AllowUsers`, `AllowGroups`, `DenyUsers`, +`DenyGroups`, `ForceCommand` or `Match` block and `pam_access` is off (mos-1127 point 1). + +Requests: | Request | Effect | |---|---| -| `list ` | prints each `authorized_keys` line of `` whose comment starts `mosaic-` | -| `add ` | appends `from="",restrict ` unless the tag exists, through tmp and rename, keeping owner and mode | -| `remove ` | drops the line with that exact tag, through tmp and rename | +| `list ` | prints each line of `/etc/ssh/mosaic-keys/` whose tag matches the tag pattern | +| `add ` | appends `from="",restrict ` unless the tag exists | +| `remove ` | drops the line with that exact tag | -Rules the script enforces on the host, so a stolen installer key can do no more than they allow: +What `mosaic-keys-write` checks, at every request (mos-1125 points 3 and 4): -- `` must be in a host-side allowlist file, `/etc/mosaic-keys/users`, written by ops-01. The install - user itself is never on it. -- The script writes the `from=` value from `/etc/mosaic-keys/from`, also ops-01's. The stack can't choose it. -- Each tag matches `^mosaic-[a-z0-9-]{1,96}$`, and each type and base64 matches the same patterns as E1. -- The script never touches a line without the `mosaic-` prefix. -- The script logs each request to syslog. +- the request matches the patterns again. Each tag matches `^mosaic-[0-9a-f]{32}$`, and each type and + base64 match E1's patterns. +- `` exists, is in `users`, and isn't uid 0, uid under 1000, `mosaic-installer` or `localadmin`. These + names and the uid rule are in the code, not in a file. +- `` is in none of the groups `sudo`, `admin`, `wheel`, `docker`, `lxd` and has no `sudo -l` line, + unless it is in `privileged`. This is the answer to mos-1125's conflict (Q-O2 asks ops-01 to confirm it). +- it never touches a line whose tag doesn't match the pattern. -The install user `I` needs write access to ``'s `authorized_keys` only. A `sudoers` line for exactly -`/usr/local/sbin/mosaic-keys-write` (a root helper the script calls) is ops-01's choice. The design needs the -effect and not a particular mechanism, and Duckworth's #423 condition (duckworth-0035) applies to whatever -ops-01 runs. +How it writes, so no path trick moves the write: -This still doesn't make the installer key safe to lose. A key that can add a line for a NOPASSWD sudo user -is root on that host. Section 9 says so. +- opens `/etc/ssh/mosaic-keys` with `O_DIRECTORY | O_NOFOLLOW` and refuses unless it is root-owned and not + group or world writable +- takes `flock` on a lock file in that dir +- opens the user's file relative to the dir fd with `O_NOFOLLOW`, and refuses one that isn't a regular + root-owned file +- writes a tmp with `O_CREAT | O_EXCL | O_NOFOLLOW`, sets mode 0644, `fsync`, `renameat` over the file, + then `fsync` on the dir +- logs to syslog the request, user, tag, key fingerprint and `SSH_CONNECTION` + +The writer is python3, standard library only, because `os.open` takes `dir_fd` and `O_NOFOLLOW` and a shell +script can't do that safely. Ubuntu with OpenSSH 9.6p1 ships python3. If ops-01 wants another language, the +checks above are the contract. + +sshd's `StrictModes yes` (on both hosts, mos-1127 point 3) also refuses a key file whose path is writable by +anyone but root or the user. + +The install proof runs on the #433 VM first (mos-1125 point 6), as ops-01 does it (mos-1127): `sshd -t`, +`systemctl reload ssh`, then a new connection. An existing session survives the reload. Rollback removes +the drop-in and reloads, which restores the old value. The run sheet's host step (#432, I3) cites these. + +This still doesn't make the installer key safe to lose. A key that can add a line for a privileged user is +root on that host. Section 9 says so. ### E4. Check -`mosaic hosts check ` connects with the installer key, the pinned `known_hosts` and 210's ssh -options, and runs `list `. A wrong pin exits 255 and refuses `host-key-mismatch`, as 210 does. Enrollment -is done when the check passes. `status` shows each host's pin fingerprints, the installer fingerprint and the -last check time. +After ops-01 installs the files and the installer line, `hosts.check ` connects as `mosaic-installer` +with the installer key, the pinned `known_hosts` and 210's ssh options, and runs `list `. A wrong pin +exits 255 and refuses `host-key-mismatch`, as 210 does, and the host stays `pending-install`. A pass records +`host.checked {host, registrySha256, installerFingerprint}`, and the host is `ready`. Only a `ready` host can +be named in a grant (R4). -`mosaic hosts remove ` is human only. It refuses while any grant names the host, then deletes the -installer key and the registry entry. ops-01 removes the installer line on the host. +The chief, the tester or the human may run `hosts.check`. It reads the host's lines and widens nothing past +what Jason approved, so it needs no decision. `status` shows each host's state, pin fingerprints, installer +fingerprint and last check time. + +`hosts.remove ` is for the human or the tester, as a revoke is (REQ-GRANT-4), because it only takes +access away. It refuses `host-in-use` while any grant names the host and `host-line-left` while any event's +`hostLineLeft` names the host and no later `sweep` cleared it (N3). Then it deletes the installer key, drops +the registry entry through the writer and from the broker, and records `host.removed`. ops-01 removes the +installer line on the host. Enrolling the host again takes a new request. ## 4. The request and the decision ### R1. Up the chain An agent that needs host access sends the chief a message, as any request to the chief goes today -(`#checkMessaging`, `broker.mjs:617-627`). There is no structured request verb in r1. F1 gives the reason: +(`#checkMessaging`, `broker.mjs:617-627`). There is no structured request verb (Q-S1). F1 gives the reason: nothing records who reports to whom, so the bus can't check a chain beyond "the chief raises it". "Not every agent may request" (Q188) is enforced at the raise. The target agent's definition must declare @@ -177,50 +299,59 @@ nothing records who reports to whom, so the bus can't check a chain beyond "the it can ask, but the chief's raise for it refuses `host-not-declared`. Which definitions declare `host` is the reviewed list of agents that may hold a host grant. -A structured `grant.ask` verb (the agent files the request, the chief raises it citing the request id, and -the broker checks the pair) is the stronger form. It is question Q-S1. - ### R2. The action -`host.grant` is a new action in `ACTIONS` and in neither `ROUTED` list, so it is a hard wait for every role -(F3, `vocabulary.mjs:88`). PRD 1.7 has no requirement for it. Like `host.sudo` in 210 section 5, it needs a -PRD line (section 8). It is never routed, because Q188 keeps grants with Jason. +The request is a `credential.mint` decision, as Honker's REQ-GRANT-1 says. `credential.mint` is already a +hard wait (`vocabulary.mjs:38`, F10), so it classifies `gated` for every role and routes to the human (F3). +No role file may list it (REQ-WAIT-2), so it can never be routed, and the hard-waits list doesn't change. -I chose a new action over `credential.mint` (F10). `credential.mint` names no host or argv, and a test pins -the hard-wait list, so either choice changes vocabulary. The narrower name keeps a future Gitea mint -separate. +r1 used a new `host.grant` action because `credential.mint` names no host or argv. That reason doesn't +hold. The binding lives in the raise's `grant` argument and the raise event body, not in the action name, +so G1 to G10 test the same things either way. The executor starts only for a decision whose raise carries +a `grant` binding. + +A `credential.mint` raise without `grant` keeps today's behaviour. Any agent may raise one, Jason may +answer it, and nothing runs. It is a question for a hand-made credential, as it is now. ### R3. Who may raise it -The broker refuses a `host.grant` raise unless the raiser's definition is `chief-of-staff` -(`b.roles[s.role].definition === STAFF_TO`, the check at `broker.mjs:593`). Refusal: `grant-chief-only`. A tester -thread can't raise it, because it routes to the human (`broker.mjs:1683`). +The broker refuses a raise that carries `grant` unless the raiser's definition is `chief-of-staff` +(`b.roles[s.role].definition === STAFF_TO`, the check at `broker.mjs:593`). Refusal: `grant-chief-only`. A +tester thread can't raise it, because it routes to the human (`broker.mjs:1683`). Any other action with a +`grant` argument refuses `invalid-request`. ### R4. What the raise binds -New raise argument `grant`, required when `action === 'host.grant'` and refused otherwise: +New raise argument `grant`, allowed only with `action === 'credential.mint'`. For a host grant: ``` -grant: { "agent": "", "host": "", "commands": [["systemctl", "status"], ...], - "rotateBy": "YYYY-MM-DD" } +grant: { "method": "host", "agent": "", "host": "", + "commands": [["systemctl", "status"], ...], "rotateBy": "YYYY-MM-DD" } ``` +`host-enroll` is E1's. A later draft adds `gitea` and `github`. Any other `method` refuses +`invalid-request`. + Checks at raise, all inside the raise transaction: - `agent` is a role instance of this business, and its definition declares `host` (R1) -- `host` is enrolled (section 3) +- `host` is `ready` (E2, E4), or the raise refuses `host-not-ready` - `commands` passes 210 D6's argv rules: no wildcard, no shell, no empty prefix, and the existing refusals for `bash` and the like - `rotateBy` is a date inside 210's rotation window The broker appends a rendered block to the question, as 210 D4 does for `host.sudo`: agent, host id, -address, user, each argv prefix JSON-quoted, the rotate date, and whether this is a new key or a change to -an existing grant's commands. The chief can't shape that block. The whole question keeps the 4096-character -cap (`broker.mjs:126`). +address, user, whether the user has sudo (from `host.enrolled`), each argv prefix JSON-quoted, the rotate +date, and whether this is a new key or a change to an existing grant's commands. The chief can't shape that +block. If the question with the block exceeds the 4096-character cap (`broker.mjs:126`), the raise refuses +`invalid-request`, as 210 D4 does (N2). The chief then splits the commands over two requests. -The raise event body gets `hostGrant = {agent, host, commandsSha256, rotateBy, registrySha256}`. -`registrySha256` hashes the host's registry entry at raise time. If ops-01 re-pins the host between raise and -Approve, the executor refuses `decision-mismatch`, and the chief raises again. +The raise event body gets `grant = {method, agent, host, commands, commandsSha256, rotateBy, registrySha256, +definitionSha256}`. The full `commands` list is stored there, and the executor reads it from that event at +X2 step 1 and checks it against `commandsSha256` (N2). `registrySha256` hashes the host's registry entry at +raise time, and `definitionSha256` hashes the agent's role definition file. REQ-GRANT-2 voids an approval +when the agent, its definition, the target or the rights change after the raise. If either hash differs at +X2 step 1, the executor refuses `decision-mismatch` and the chief raises again. ### R5. Jason decides @@ -235,41 +366,58 @@ the client, so #435 needs no executor change. At resolve. Today a resolution runs nothing (F4), and the raiser comes back later to consume the decision. Here the raiser is the chief, and Q188 says no agent creates the grant, so the chief must not be the one whose call does it. The bus child starts the executor itself after the human's `decision.resolve` commits, -when all of these hold: the decision's action is `host.grant`, the route is `human`, the choice equals -`approvalChoice`, and `via === 'cli'`. +when all of these hold: the decision's action is `credential.mint` and its raise carries `grant`, the route +is `human`, the choice equals `approvalChoice`, and `via === 'cli'`. A resolve by an arbiter, the tester or +an agent starts nothing (REQ-GRANT-2). The resolve call returns at once with `{grant: "started"}`. It does not wait on ssh. Jason sees the outcome in `mosaic hosts status` and as a message in his inbox from `system` when the executor ends. -The executor consumes the decision through the same `action.allowed` event `#consumeAuthority` writes -(`broker.mjs:808-831`), with `operation: 'host.grant'`, in a transaction before any remote step. A second -start finds it spent and refuses `decision-consumed`. One executor runs at a time per (agent, host), -through 210's busy map (210: `credentials.mjs:118`). +The executor can't use `#consumeAuthority`, because that needs the raiser's agent session (F4, N1). It gets +its own path, `#consumeGrant(business, decisionId, method)`. In one transaction it checks the decision is +resolved yes by the human via cli, checks `#spent` the same way (`broker.mjs:808-831`), and writes the +`action.allowed` event with `operation: 'grant.'` and actor `system/grant`. A second start finds it +spent and refuses `decision-consumed`. It is a private method, and only the executor calls it. ### X2. Steps -1. **Recheck** inside one transaction: the decision is resolved yes by the human via cli and unspent, the - agent and its definition still declare `host`, the registry entry hashes to `registrySha256`, and no - revoke of this (agent, host) is newer than the raise. Then write the consume event. Any failure is - `grant.failed` with its code, and nothing remote has happened. -2. **Key.** For a new grant, `ssh-keygen -t ed25519 -N '' -C ` in a fresh 0700 dir under - `/secrets/run`, where tag is `mosaic---`. For a commands change - with a live key, skip to step 5. -3. **Install.** Connect as `I` with the installer key, pinned (210's options), and send - `add `. If the tag exists on the host, `remove` it first, because an old line - under the same tag belongs to a key the stack no longer holds. +Before step 1 the executor claims the (agent, `host.`) entry in `#inflight` (X5). A claim that +finds an entry refuses `host-busy`, the same as 210's `use` (210: `credentials.mjs:355`). + +1. **Recheck and consume** inside one transaction: `#consumeGrant` (X1), the agent and its definition still + declare `host`, the definition hashes to `definitionSha256`, the registry entry hashes to + `registrySha256`, the host is `ready`, the `commands` in the raise event hash to `commandsSha256`, and no + revoke of this (agent, host) is newer than the raise. Any failure is `grant.failed` with its code, and + nothing remote has happened. +2. **Key.** The tag is `mosaic-` and the first 32 hex digits of sha256 over business, agent and hostId + joined with a NUL byte. Names match `ID_PATTERN` (`vocabulary.mjs:143`), which has no NUL, so the join is + one to one, and the tag has a fixed length the host pattern accepts (B4). For a new grant, + `ssh-keygen -t ed25519 -N '' -C ` in a fresh 0700 dir under `/secrets/run`. For a + commands change with a live key, skip to step 5. +3. **Install.** Write `grant.step {decision, agent, host, user, tag, step: 'install'}`. Connect as + `mosaic-installer` with the installer key, pinned (210's options), and run `list `. If the tag is + there, `remove` it first: the tag is this agent's on this host, and step 2 only runs when the stack holds + no live key for it. Then `add `. 4. **Verify.** Connect as `` with the new key through a short-lived ssh-agent (210 D7) and run `true`. The connection proves the line, the `from=` and the pin together. A failure here runs `remove ` and ends `grant.failed`. -5. **Business file.** One write through the grant writer (W1): `roles..hosts..commands` set to - the decided list, and for a new key `roles..credentials["host."] = {"store": true, - "rotateBy": D}`. -6. **Store.** 210 `issue(agent, "host.", privateKey, rotateBy)` writes the key file and fills - memory. The tmp dir from step 2 is removed in a `finally`. -7. **Event and message.** `grant.applied {decision, agent, host, fingerprint, commandsSha256}` and a message - to the human and the chief. The private key appears in no event, message or log (210 D8's +5. **Go live.** Write `grant.step {..., step: 'commit', previous}`, where `previous` is the grant's + `commands` before this decision, or `null` for a new key. Then check the abort signal (X5), and with no + `await` between, run 210 `issue(agent, "host.", privateKey, rotateBy, commit)`. For a new key the + reference is registered first (X4). `issue` writes the key file, then calls `commit`, which does one W1 + write (`roles..hosts..commands` set to the decided list, and for a new key + `roles..credentials["host."] = {"store": true, "rotateBy": D}`) and applies it to the + broker (X4). Then `issue` fills memory. For a commands change, step 5 is the abort check, the W1 write + and the broker apply, with no `issue`. The tmp dir from step 2 is removed in a `finally`. +6. **Event and message.** `grant.applied {decision, agent, host, user, tag, fingerprint, commandsSha256}` + and a message to the human and the chief. The private key appears in no event, message or log (210 D8's `assertCleanKey` runs on every event body). +After step 6, or after any failure, the executor releases its `#inflight` entry in a `finally`. + +The tag is recorded in `grant.step` and `grant.applied`, so `list` output and `sweep` can be matched to this +business's grants without trusting a comment on the host (B4). + ### X3. Failure and restart The steps are ordered so a failure leaves nothing an agent can use: @@ -280,47 +428,105 @@ The steps are ordered so a failure leaves nothing an agent can use: | 2 | tmp dir | `finally`, and the start sweep | | 3 | maybe a line with a key nobody holds | `remove `, best effort | | 4 | a line, no grant | `remove`, best effort | -| 5 | a line, no grant | `remove`, best effort | -| 6 | a line, a grant, no key file: calls refuse `credential-unavailable` (199 D3a) | `remove`, then a writer pass that drops layers 3 and 4 | +| 5 before `commit` | a line, a key file, no reference in the file | unlink the key file, unregister (X4), `remove` | +| 5 after `commit` | a line, a grant, a key file | W1 back to `previous`, broker apply, 210 `revoke`, `remove` | A best-effort `remove` that fails adds `hostLineLeft: ` to `grant.failed`, and `status` shows it until -`mosaic hosts sweep ` (human or tester) succeeds. `sweep` runs `list ` and removes every -`mosaic-` line whose tag has no live grant. +`mosaic hosts sweep ` (human or tester) succeeds. `sweep` runs `list ` and removes each line +whose tag is in this business's recorded `grant.step` tags and has no live grant. A line with any other tag +is left alone, so a sweep can't touch another business's line on the same user (B4). -A bus child that dies mid-executor leaves a spent decision with no `grant.applied` or `grant.failed`. At start, -the bus records `grant.failed {code: 'interrupted'}` for each such decision and runs no remote step. Jason -gets a message. The chief raises again, and Jason approves again. I chose a new decision over an automatic -retry because Jason approved one attempt at one moment, and a retry after a restart might be hours later. +**Restart.** A bus child that dies mid-executor leaves a spent decision with no `grant.applied` or +`grant.failed`. At start, before `Credentials` and the broker load, the bus reconciles each one from its last +`grant.step` (or `host.enroll.step`), back to the state before the decision (B3): + +| Last step | Local undo, before load | Remote undo, after the bus is up | +|---|---|---| +| none | none (the tmp dir goes in 210's run-dir sweep) | none | +| `install` | none | `remove ` | +| `commit`, new key | unlink the key file if present, W1 drops the grant and the reference if present | `remove ` | +| `commit`, commands change | W1 sets `commands` back to `previous` | none, the key and line didn't change | +| enrollment `registry` | delete the installer key if present, W1 drops the registry entry | none | + +Then it records `grant.failed {code: 'interrupted', tag}` with the tag listed every time, and `hostLineLeft: +` if the remote undo failed. Jason gets a message. The chief raises again, and Jason approves again. I +chose a new decision over an automatic retry because Jason approved one attempt at one moment, and a retry +after a restart might be hours later. The remote undo only takes access away, so it needs no decision. + +### X4. Taking effect in the running bus (B1) + +The running bus never reloads the business file (F11), so each write has to reach memory in the same call: + +- The broker gets one method, `applyGrant(business, delta)`, that sets or deletes exactly W1's keys in its + clone: `hosts.` and `roles..hosts.`. It isn't a verb, and no request reaches it. + The executor, revoke, `hosts.enroll` and `hosts.remove` call it right after each W1 commit. The next + `host.run` reads the new entry (210: `broker.mjs:1731-1735`). +- `Credentials` gets `register(instance, service, ref)`, which runs the constructor's reference checks for + one entry (210: `credentials.mjs:128-143`, moved into a shared method) and sets `#refs`, and + `unregister`, which drops an entry with no token. The executor registers before `issue`, because `issue` + refuses an unknown service (210: `credentials.mjs:235-238`). +- Revoke keeps the reference, which reads as revoked with no file (199), so a later grant needs no new + `register`. + +The broker's whole-business clone stays as it is. Only W1's keys move at runtime, so a hand edit of any +other key still needs a restart, as today. + +### X5. Revoke while the executor runs (B2) + +The executor's `#inflight` entry is an `AbortController` (210: `credentials.mjs:118`), the same entry that +`revoke`'s `abort()` reaches (210: `credentials.mjs:289`). A new `Credentials.claim(instance, service)` sets +it and returns the signal, and `release` deletes it. + +- A revoke before the executor starts writes `grant.revoked`. Step 1's "no revoke newer than the raise" + refuses. +- A revoke during steps 2 to 5 aborts the claim. With no reference yet, `revoke` today refuses + `credential-unknown` (210: `credentials.mjs:235-238`). r2 changes that: a `host.` service with no + reference but a live claim aborts the claim and answers `{file: false, pending: true}`, and the broker + records `grant.revoked {pending: true}`. With neither, it still refuses `credential-unknown`. +- The executor checks `signal.aborted` after each `await`. At step 5 it checks right before `issue`, and + nothing between the check and `issue` awaits, so no revoke can run in between. That matters, because + `issue` clears the revoked mark (210: `credentials.mjs:279`). +- On abort the executor undoes as X3's failure table does at that step, then ends + `grant.failed {code: 'revoked'}`. +- A revoke after step 5 is an ordinary revoke (section 6). + +While the executor holds the claim, a `host.run` for the same agent and host refuses `host-busy`. That only +matters for a commands change on a live key, and it lasts until the executor ends. ### W1. The grant writer A second writer on the same lock, revision, tmp, rename and receipt steps as `organizationWriter.write` (F6), with its own key set: -- `hosts.`, written by `hosts.enroll` and `hosts.remove` only +- `hosts.`, written by `hosts.enroll` (the executor) and `hosts.remove` only - `roles..hosts.` and `roles..credentials["host."]`, written by the executor and by revoke only -The receipt's actor is `grant:`, `revoke:` or `enroll:human`. No broker verb takes these -keys from a request body. `organization.set`, `limits.set` and `instances.set` keep refusing `hosts`, which -210 T3 already tests. +The receipt's actor is `grant:`, `enroll:`, `revoke:`, `remove:` or +`reconcile:`. No broker verb takes these keys from a request body. `organization.set`, +`limits.set` and `instances.set` keep refusing `hosts`, which 210 T3 already tests. This replaces 210 section 4's hand edits for layers 2 to 4. A hand edit of the business file still works, -because the file is Jason's, and the loader checks it as 210 D3 does. +because the file is Jason's, and the loader checks it as 210 D3 does. It takes effect at the next restart. ## 6. Revoke Q188: instant, by Jason, Mos or the tester. `mosaic hosts revoke --reason R`, with -`--tester` for the tester (210 D9 already gives the tester `revoke`). Mos revokes as the tester or through -Jason's terminal. Q-M1 asks Mos which. +`--tester` for the tester (210 D9 already gives the tester `revoke`). Mos revokes as the tester with +`--tester`, never from Jason's terminal (mos-1120, Q173). The authority is Jason's yes to Q10 point 6. +Every revoke is logged with who made it (REQ-GRANT-4). + +Honker's REQ-GRANT-4 names "the user, the assistant or the tester". The assistant is a stack role +(`vocabulary.mjs:131`), and Q188 names Mos, who acts as the tester. This design gives revoke to the human +and the tester only. Q-S5 asks whether the assistant role should have it too. Order, local first so the part that matters can't wait on the network: -1. 210 `revoke`: memory, abort the `host.run` in flight (SIGKILL to the ssh process group), unlink the key - file. The call answers `credential-unavailable`. This is the "instant" part, and it doesn't touch the - network. -2. The grant writer drops `roles..hosts.`. The credential reference stays as a store - reference with no file, which reads as revoked (199). A new grant needs a new decision. +1. 210 `revoke`: memory, abort the `host.run` or the executor in flight (X5), unlink the key file. The call + answers `credential-unavailable`. This is the "instant" part, and it doesn't touch the network. +2. The grant writer drops `roles..hosts.`, and `applyGrant` drops it from the broker (X4). + The credential reference stays as a store reference with no file, which reads as revoked (199). A new + grant needs a new decision. 3. `remove ` on the host through the installer. If the host can't be reached, the event is `grant.revoked {hostLineLeft: }`, and `sweep` clears it later. @@ -330,8 +536,8 @@ something copied the key file before the revoke, which section 9's same-user lim Remote processes that the killed ssh started may keep running (210 section 6). Revoke cuts the stack's connection, not what already runs on the host. -`hosts remove` (E4) refuses while grants exist. Revoking the installer itself is `hosts remove` and ops-01 -deleting the line. +`hosts remove` (E4) refuses while grants exist or a line is left. Revoking the installer itself is +`hosts remove` and ops-01 deleting the line. ## 7. Row 210's per-call decision becomes manual mode @@ -343,28 +549,41 @@ What changes is when the class is `gated`: - Under PRD 1.7, `host.sudo` is a hard wait, so every call is gated. That is today. - Under PRD 1.8 with REQ-CRED-4, `host.sudo` is routed, and a definition that lists it within-role runs granted commands with no decision (210 section 5). -- Manual mode (Q188) puts the per-call decision back. I propose a business-file key, human-only and written - through the grant writer, `"manual": ["host.sudo"]`, a list of routed actions that classify `gated` for - every role while listed. `#classification` checks it first (`broker.mjs:597-609`). A list rather than a - flag, so Jason can make host commands manual without making every routed action manual. +- Manual mode (Q188) puts the per-call decision back. Honker's REQ-TRUST-2 takes the list form: the + organization file holds a list of actions in manual mode, empty by default, set only by the user. While + an action is listed, it classifies `gated` for every role. `#classification` checks the list first + (`broker.mjs:597-609`). -The manual mode key is PRD 1.8 territory. Honker drafts REQ-CRED-4 through Sage. I bring this proposal to that -draft and don't build it in the grant rows. Q-S3 asks Sage where it belongs. +Manual mode is built from REQ-TRUST-2, not in the grant rows (sage-1527 Q-S3). -## 8. PRD lines (proposal for 1.8, beside REQ-CRED-4, through Sage) +## 8. PRD lines -> **REQ-CRED-5.** Only the chief raises a host grant. Jason approves it, and stack code then creates the key, -> installs it on the host, and records the grant. No agent writes a grant or holds a host key's private half -> outside the store. A grant names an agent, a host and exact argv prefixes. Revoke takes effect at once on -> the stack's side, and the host line is removed when the host can be reached. +Honker's PRD 1.8 draft (honker/row194-b2 3c30f1036) carries this design as REQ-GRANT-1 to -4, generalized to +all three methods. There is no REQ-CRED-5. Duckworth's PRD review (duckworth-0057) notes that this design +uses `credential.mint` with the method in the `grant` body, not a new `host.grant` action. Points r2 sends +Honker to match: -I don't edit the PRD. Sage takes this to Mos with Honker's REQ-CRED-4. +- REQ-GRANT-2 says a host's identity comes from the host side "when the user enrolls it". Under mos-1120 + the user approves the enrollment and the stack runs it, so "when the user approves its enrollment" fits + better. +- REQ-GRANT-4 names the assistant as a revoker. Q188 names Mos, who acts as the tester (Q-S5). +- An enrollment names a target user with sudo, and the Approve covers that user by name (E1, mos-1125). + +I don't edit the PRD. ## 9. Limits, said plainly -- **The installer key is root on its host.** It can add a line for a NOPASSWD sudo user. It sits in - `/secrets`, readable by the stack's OS user like every other key (199 D6). `mosaic-keys` limits - which users it can touch, not what those users can do. +- **The installer key is root on a host with a privileged user.** It can add a line for a user in + `privileged`, and that user has sudo. It sits in `/secrets`, readable by the stack's OS user + like every other key (199 D6). The writer limits which users it can touch, and a user with sudo is + touchable only after Jason approved that user by name in the enrollment (E1, E3). It doesn't limit what + those users can do. +- **A host line has no `command=`** (ops-01, mos-1125). `add` writes `from=` and `restrict` only, so the host + doesn't enforce the granted argv prefixes. The bus enforces them (210 D6). A copied agent key is a full + shell as that user from the stack's address. A forced command per key that checks + `SSH_ORIGINAL_COMMAND` against the granted list would close that, but it means the writer also carries + every commands change to the host, and 210's argv encoding has to round-trip through it. I'd make it a + follow-up row and not part of rows a and b. - **The seat network isn't confined** (`seat/src/sandbox.mjs:29-31`, 210 section 6). An agent granted a command that writes a file as root can plant its own key and ssh around the bus. `from=` doesn't help, because the sandbox shares the stack's address. A network-confined sandbox closes it (Sage's follow-up). @@ -373,45 +592,73 @@ I don't edit the PRD. Sage takes this to Mos with Honker's REQ-CRED-4. - **Approve is not "runs without a click"** until PRD 1.8 routes `host.sudo` (section 0). - **Enrollment still needs ops-01 once per host.** I don't see a way around it without a trust anchor on the host, and I'd rather say so than claim the flow is hands-free. -- **The pin trusts what Jason pastes.** Whoever writes the stdin of `hosts enroll` chooses the trusted key. +- **The pin trusts the path from ops-01 to the chief.** A key changed on the way makes E4 fail closed, + unless whoever changed it can also intercept the stack's connection to the host. Jason's Approve shows + the fingerprints, which he can compare with ops-01's if he wants to. +- **Tags assume one stack per business id.** Two stacks that both run a business with the same id and grant + the same agent name on the same host user would compute the same tag. Nothing runs two stacks today. ## 10. Tests and mutants (for the rows, not built here) Tests use a scratch sshd on 127.0.0.1 with throwaway keys, as row 210 does, with `mosaic-keys` running as the -scratch sshd's forced command against a scratch `authorized_keys`. No real host is contacted. +scratch sshd's forced command. The writer's checks are tested by calling its functions with a temp dir as +the root and a fake group and sudo lookup. The installed entry point fixes the `/etc` paths and passes no +such argument. No real host is contacted. | Test | Claim | |---|---| -| G1 | a `host.grant` raise from a role that isn't the chief refuses `grant-chief-only`, and nothing is stored | +| G1 | a `credential.mint` raise with `grant` from a role that isn't the chief refuses `grant-chief-only`, and nothing is stored. A `grant` on any other action refuses `invalid-request` | | G2 | a raise for an agent whose definition lacks `host` refuses `host-not-declared` | | G3 | a raise with a wildcard, shell or empty argv prefix refuses, the same cases as 210 D6 | -| G4 | the rendered block is appended by the broker, the chief's question text can't forge it, and the 4096 cap holds | -| G5 | `host.grant` classifies gated for every role and routes to `human`, and a tester thread can't raise it | +| G4 | the rendered block is appended by the broker, the chief's question text can't forge it, and a question over 4096 characters with the block refuses `invalid-request` | +| G5 | a `credential.mint` raise with `grant` routes to `human`, a tester thread can't raise it, and an approved `credential.mint` without `grant` runs nothing | | G6 | a yes from the human via cli starts the executor, while a no, a withdraw, or an arbiter resolve start nothing | -| G7 | executor end to end on scratch sshd: line installed with the `from=` and tag, verify login works, layers 3 and 4 written with a receipt, key file 0600, `grant.applied` | +| G7 | executor end to end on scratch sshd: line installed with the `from=` and tag, verify login works, layers 3 and 4 written with a receipt, key file 0600, `grant.applied` with the tag | | G8 | each failure row of X3 leaves the stated state, and a failed `remove` leaves `hostLineLeft` | -| G9 | a registry change between raise and Approve refuses `decision-mismatch` before any remote step | -| G10 | a second start for the same decision refuses `decision-consumed` | -| G11 | a bus restart mid-executor gives `grant.failed interrupted` and runs no remote step | +| G9 | a registry, role definition or commands change between raise and Approve refuses `decision-mismatch` before any remote step | +| G10 | a second start for the same decision refuses `decision-consumed`, and `#consumeGrant` refuses an unresolved or agent-resolved decision | +| G11 | a bus restart after `install`, after the key file is written and before `commit`, and after `commit` each reconcile to the state before the decision: no key file, no grant, no line, `grant.failed interrupted` with the tag. A restart after an enrollment's `registry` step drops the entry and the installer key | | G12 | revoke: the call in flight dies, the key file is gone, the grant entry is gone, the host line is gone, in that order | | G13 | revoke with the host down still does steps 1 and 2 and reports `hostLineLeft` | | G14 | `organization.set`, `limits.set` and `instances.set` still refuse `hosts` and `manual` | | G15 | no event, message, log line or receipt holds private key material (210 D8 check on every body) | -| G16 | `mosaic-keys`: users outside its list, lines without the `mosaic-` prefix, bad tags and bad key text all refuse, and `from=` comes from the host file | -| G17 | `hosts enroll` refuses loopback and this machine's names and bad key lines, and prints no private material | -| G18 | `hosts check` with a wrong pin exits 255, refuses `host-key-mismatch` and runs nothing | +| G16 | `mosaic-keys` refuses bad requests without calling the writer. The writer refuses users outside `users`, root, uid under 1000, `mosaic-installer`, `localadmin`, a member of each refused group, a user with a `sudo -l` line who isn't in `privileged`, bad tags and bad key text, and takes `from=` from the host file | +| G17 | a `host-enroll` raise refuses loopback and this machine's names, bad key lines, an enrolled id and the reserved users. On Approve the executor writes the entry and the installer key 0600, records `host.enrolled`, and its message holds no private material | +| G18 | `hosts.check` with a wrong pin exits 255, refuses `host-key-mismatch`, runs nothing and leaves the host `pending-install` | +| G19 | a grant naming a host that isn't `ready` refuses `host-not-ready`, including after a hand edit changes the registry hash | +| G20 | `hosts.check` from the chief, the tester and the human marks the host `ready`, and any other role refuses | +| G21 | `hosts.remove` refuses `host-in-use` while a grant names the host, `host-line-left` while a line is left, and any agent other than the tester | +| G22 | one bus, no restart: enroll, check, grant, a `host.run` that works, a commands change that the next `host.run` uses, and a revoke that the next `host.run` refuses `credential-unavailable` (B1) | +| G23 | a revoke during install (a scratch `mosaic-keys` that waits on a fifo) ends `grant.failed revoked` with no key file, no grant and no line. A revoke before the executor starts refuses at step 1. A revoke with no reference and no claim still refuses `credential-unknown` (B2) | +| G24 | agent and host names at 64 characters with `.` and `_` give distinct tags of the fixed length. `sweep` leaves another business's line and an unrecorded `mosaic-` line on the same user (B4) | +| G25 | the writer refuses a symlinked `/etc/ssh/mosaic-keys`, a symlinked user file, a group-writable dir and an existing tmp, and a written file is root-owned 0644 (B5) | Mutants, a first list. The row's BUILD.md numbers them and adds more: - drop the chief check +- start the executor on a `credential.mint` with no `grant` +- skip the `ready` check +- skip the definition hash compare - drop the definition check - skip the commands hash compare - skip the registry hash compare - swap the order of install and the business write - skip `remove` on a verify failure - skip the start sweep -- drop `from=` in `mosaic-keys` -- let `mosaic-keys` touch an unprefixed line +- skip the reconcile's remote `remove` +- skip the `grant.step` before install +- skip `applyGrant` after a grant, and after a revoke +- skip `register` before `issue` +- drop the abort check before `issue` +- add an `await` between the abort check and `issue` +- revoke ignores a claim with no reference +- build the tag without the business +- `sweep` removes any `mosaic-` line +- drop `from=` in the writer +- let the writer touch a line outside the tag pattern +- drop `O_NOFOLLOW` on the user file +- drop one group from the writer's refused list +- let the writer accept a sudo user not in `privileged` - run revoke's host step before the local steps - start the executor on an arbiter resolve - start it on `no` @@ -419,25 +666,40 @@ Mutants, a first list. The row's BUILD.md numbers them and adds more: ## 11. Not in this draft -- Gitea and GitHub grants. The same flow: chief raise, Jason approves, the executor mints a token through - an admin credential Jason enters, writes the reference, and revoke deletes the token at the service. - The admin scope is refused today (`vocabulary.mjs:105-106`), so that is a design of its own. +- Gitea and GitHub grants (`method` `gitea` and `github`). The same flow: chief raise, Jason approves, the + executor mints a token through an admin credential Jason enters, writes the reference, and revoke deletes + the token at the service. The admin scope is refused today (`vocabulary.mjs:105-106`), so that is a + design of its own. - The Console Approve (#435) and the Console token entry. Both wait on Q175 sign-in (#412). - The network-confined sandbox (Sage's follow-up from 210 section 6). +- A per-key forced command on the host (section 9). +- Changing an enrolled host's user or sudo mark. Today that is `hosts remove` and a new enrollment. ## 12. Questions -- **Q-S1 (Sage).** Is "message the chief, and the definition gates the raise" enough for "up the chain" - in the first row? Or do you want the `grant.ask` verb (R1) now? It adds one verb and one table. I'd - start without it. -- **Q-S2 (Sage, Mos).** Where should installer keys live: `secrets/hosts///installer` - (my proposal) or under `secrets/mosaic-stack/`? -- **Q-S3 (Sage).** Does manual mode (section 7) go into Honker's PRD 1.8 draft beside REQ-CRED-4, and is - REQ-CRED-5 (section 8) one line Sage carries with it? -- **Q-S4 (Sage).** My suggested split, yours to size. Row a: enrollment, `mosaic-keys`, the grant writer, - and `hosts status`. Row b: the `host.grant` action, the raise binding, the executor and revoke. Row b - waits on row a and on 210's landing. Both need the PRD line before landing, as 210 did. -- **Q-M1 (Mos).** How does Mos revoke: as the tester (`--tester`) or from Jason's terminal? -- **Q-O1 (ops-01, through Mos).** Is a root-owned forced-command script with a host-side user list and - `from=` file acceptable on the hosts? If ops-01 would rather run something else on the host, the - stack-side design stays the same as long as the three requests in E3 behave as stated. +Answered since r1: + +- Q-S1 (sage-1527): no `grant.ask` in row a. Messaging the chief, with the definition gating the raise, is + enough. If review finds a forgery path, the verb comes then. Duckworth agrees (N4). +- Q-S2 (mos-1120, sage-1527): `/secrets/hosts///installer`. +- Q-S3 (sage-1527): manual mode is Honker's REQ-TRUST-2, and REQ-CRED-5 is folded into REQ-GRANT-1 to -4. +- Q-S4 (sage-1527): the split stands, both rows are mine, and Duckworth reviews both. + - Row a: enrollment as the approved request, the four `tools/host` files, the grant writer, `applyGrant` + and `hosts status`, about 1 day. + - Row b: the raise binding, the executor, reconcile and revoke, after row a, about 1.5 days. + - Both come after row 210 lands and need the 1.8 line before landing. Target: in review by 10-24. +- Q-M1 (mos-1120): Mos revokes as the tester, with `--tester`. +- Q-O1 (ops-01 through mos-1125): yes, with the six changes now in E3. + +Open: + +- **Q-O2 (ops-01).** mos-1125's conflict: the writer refuses every sudo user, and a `host.sudo` grant needs + one. r2 takes Mos's recommendation. The enrollment names the user and marks sudo, Jason's Approve covers + it, ops-01 writes it to `/etc/mosaic-keys/privileged`, and the writer refuses a sudo user not in that + file. I've asked ops-01 to confirm. +- **Q-M2 (Mos).** How do ops-01's host keys and host details reach the chief? I don't know whether ops-01 is + a role instance in the business that can message the chief, or whether Mos relays them as the tester. + The design only needs them in the chief's raise. E4 fails closed on a changed key either way. +- **Q-S5 (Sage, Honker).** REQ-GRANT-4 lists the assistant as a revoker, while Q188 names Jason, Mos and the + tester. Should the assistant role get `hosts.revoke`? Taking access away needs no new trust, so I'd allow + it if Jason wants it, but it is a new authority for a stack role and should be his call.