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)

This commit is contained in:
jason.woltje committed 2026-10-20 17:26:09 -05:00
1 parent f6b6a04b60
commit eb4c756a14
1 file changed
+439 -177
+439 -177
View File
@@ -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 Owner: Gizmoduck. Reviewer: Duckworth. Sizing and row assignment: Sage. Design only. Code starts when
confirms the row and owner (mos-1115). 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 Sources: Q188 (DECISIONS.md:1642, jason-master-restart 8ecf252), mos-1115, mos-1120, mos-1125, mos-1127,
its approved candidate 455964f23. Code cites are `path:line` at origin/refactor f41729178 unless they say sage-1527, Duckworth's r1 review (REVIEW-r1.md, duckworth/reviews d025ef492, corrected at 749080115),
"210:", which means the row 210 candidate 455964f23 (the same code as the branch head a2fd30390). 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 ## 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 - 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 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 `#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 already cites it (`broker.mjs:808-831`). `#consumeAuthority` needs the raiser's own agent session: it
resolution, and even there the agent's own `land_work` call does the work (`cli/src/launcher.mjs:555-600`). 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`, - 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`), `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`). 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:** - F7. **Row 210's pieces this path reuses:**
- the agent key store at `<configDir>/secrets/agents/<business>/<agent>/host.<id>` (210: - the agent key store at `<configDir>/secrets/agents/<business>/<agent>/host.<id>` (210:
`bus/src/credentials.mjs:153-157`) `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: - `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: - pinned `known_hosts` built from the registry's `hostKeys` with `StrictHostKeyChecking=yes` (210:
`bus/src/host-run.mjs:36`, `:170`) `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 - 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`). (`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). Vikunja #435 adds Approve after Q175 sign-in (#412).
- F10. **`credential.mint` is already a hard wait with no executor** (`vocabulary.mjs:38`). - 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 ## 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) mosaic decide (later: Console Approve, #435)
| |
bus child grant executor (on resolve, approve only) 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 Revoke runs the other way: memory, then the in-flight call, then the key file, then the business file, then
business file. 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 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 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 <hostId> --address A --port P --user U --install-user I`, with the host's public keys on ### E1. The request
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.
The Console form for this (Q188: "Jason enters ... in the WebUI") waits on #412 like Approve does. Until ops-01 reads the host's public keys on the host (`/etc/ssh/ssh_host_*_key.pub`) and submits them with the
then the CLI is the only way in. 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": "<hostId>", "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.<hostId>` registry entry through the grant writer (section 5, W1), with `hostKeys` set The rendered block shows each host key's SHA256 fingerprint as well as the line, so Jason can compare it with
to the stdin lines what ops-01 reads on the host. With `"sudo": true` it adds one line in plain words: "U has sudo on this host.
2. generates an installer key pair with `ssh-keygen -t ed25519 -N '' -C mosaic-<business>-installer-<hostId>` Approving lets later grants for U be installed, and section 9's first limit applies to U."
in a 0700 dir under `<configDir>/secrets/run`, the dir row 210 already sweeps at start
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.<hostId>` 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-<tag>`, where `<tag>`
is the hash of section 5's X2 step 2 over (business, `@installer`, hostId), in a 0700 dir under
`<configDir>/secrets/run`, the dir row 210 already sweeps at start
3. moves the private half to `<configDir>/secrets/hosts/<business>/<hostId>/installer`, mode 0600 in 0700 3. moves the private half to `<configDir>/secrets/hosts/<business>/<hostId>/installer`, mode 0600 in 0700
dirs, by the same tmp, fsync, rename steps as 210 `issue` dirs, by the same tmp, fsync, rename steps as 210 `issue`. The path follows row 199 D1's
4. prints the installer's `authorized_keys` line for ops-01: `secrets/agents/<business>/...` (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)
``` No private material goes out. A failure at step 2 or 3 drops the registry entry again through the writer and
from="<stack host address>",restrict,command="/usr/local/sbin/mosaic-keys" ssh-ed25519 AAAA... mosaic-<business>-installer-<hostId> 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 The host's state lives in events, not in the business file (F12):
`secrets/mosaic-stack/...`, which is the Console's (`webui/src/sso.mjs:35`). That is question Q-S2.
### 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 A hand edit of the registry entry changes its hash, so the host stops being `ready` until the next check.
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`: ### 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 <request>`. 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/<nn>-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 | | Request | Effect |
|---|---| |---|---|
| `list <user>` | prints each `authorized_keys` line of `<user>` whose comment starts `mosaic-` | | `list <user>` | prints each line of `/etc/ssh/mosaic-keys/<user>` whose tag matches the tag pattern |
| `add <user> <type> <base64> <tag>` | appends `from="<stack>",restrict <type> <base64> <tag>` unless the tag exists, through tmp and rename, keeping owner and mode | | `add <user> <type> <base64> <tag>` | appends `from="<from>",restrict <type> <base64> <tag>` unless the tag exists |
| `remove <user> <tag>` | drops the line with that exact tag, through tmp and rename | | `remove <user> <tag>` | 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):
- `<user>` must be in a host-side allowlist file, `/etc/mosaic-keys/users`, written by ops-01. The install - the request matches the patterns again. Each tag matches `^mosaic-[0-9a-f]{32}$`, and each type and
user itself is never on it. base64 match E1's patterns.
- The script writes the `from=` value from `/etc/mosaic-keys/from`, also ops-01's. The stack can't choose it. - `<user>` exists, is in `users`, and isn't uid 0, uid under 1000, `mosaic-installer` or `localadmin`. These
- Each tag matches `^mosaic-[a-z0-9-]{1,96}$`, and each type and base64 matches the same patterns as E1. names and the uid rule are in the code, not in a file.
- The script never touches a line without the `mosaic-` prefix. - `<user>` is in none of the groups `sudo`, `admin`, `wheel`, `docker`, `lxd` and has no `sudo -l` line,
- The script logs each request to syslog. 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 `<user>`'s `authorized_keys` only. A `sudoers` line for exactly How it writes, so no path trick moves the write:
`/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.
This still doesn't make the installer key safe to lose. A key that can add a line for a NOPASSWD sudo user - opens `/etc/ssh/mosaic-keys` with `O_DIRECTORY | O_NOFOLLOW` and refuses unless it is root-owned and not
is root on that host. Section 9 says so. 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 ### E4. Check
`mosaic hosts check <hostId>` connects with the installer key, the pinned `known_hosts` and 210's ssh After ops-01 installs the files and the installer line, `hosts.check <hostId>` connects as `mosaic-installer`
options, and runs `list <user>`. A wrong pin exits 255 and refuses `host-key-mismatch`, as 210 does. Enrollment with the installer key, the pinned `known_hosts` and 210's ssh options, and runs `list <user>`. A wrong pin
is done when the check passes. `status` shows each host's pin fingerprints, the installer fingerprint and the exits 255 and refuses `host-key-mismatch`, as 210 does, and the host stays `pending-install`. A pass records
last check time. `host.checked {host, registrySha256, installerFingerprint}`, and the host is `ready`. Only a `ready` host can
be named in a grant (R4).
`mosaic hosts remove <hostId>` is human only. It refuses while any grant names the host, then deletes the The chief, the tester or the human may run `hosts.check`. It reads the host's lines and widens nothing past
installer key and the registry entry. ops-01 removes the installer line on the host. what Jason approved, so it needs no decision. `status` shows each host's state, pin fingerprints, installer
fingerprint and last check time.
`hosts.remove <hostId>` 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 ## 4. The request and the decision
### R1. Up the chain ### R1. Up the chain
An agent that needs host access sends the chief a message, as any request to the chief goes today 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". 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 "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 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. 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 ### 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 The request is a `credential.mint` decision, as Honker's REQ-GRANT-1 says. `credential.mint` is already a
(F3, `vocabulary.mjs:88`). PRD 1.7 has no requirement for it. Like `host.sudo` in 210 section 5, it needs a hard wait (`vocabulary.mjs:38`, F10), so it classifies `gated` for every role and routes to the human (F3).
PRD line (section 8). It is never routed, because Q188 keeps grants with Jason. 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 r1 used a new `host.grant` action because `credential.mint` names no host or argv. That reason doesn't
the hard-wait list, so either choice changes vocabulary. The narrower name keeps a future Gitea mint hold. The binding lives in the raise's `grant` argument and the raise event body, not in the action name,
separate. 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 ### R3. Who may raise it
The broker refuses a `host.grant` raise unless the raiser's definition is `chief-of-staff` 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 (`b.roles[s.role].definition === STAFF_TO`, the check at `broker.mjs:593`). Refusal: `grant-chief-only`. A
thread can't raise it, because it routes to the human (`broker.mjs:1683`). 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 ### 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": "<instance>", "host": "<hostId>", "commands": [["systemctl", "status"], ...], grant: { "method": "host", "agent": "<instance>", "host": "<hostId>",
"rotateBy": "YYYY-MM-DD" } "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: Checks at raise, all inside the raise transaction:
- `agent` is a role instance of this business, and its definition declares `host` (R1) - `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 - `commands` passes 210 D6's argv rules: no wildcard, no shell, no empty prefix, and the existing
refusals for `bash` and the like refusals for `bash` and the like
- `rotateBy` is a date inside 210's rotation window - `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, 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 address, user, whether the user has sudo (from `host.enrolled`), each argv prefix JSON-quoted, the rotate
an existing grant's commands. The chief can't shape that block. The whole question keeps the 4096-character date, and whether this is a new key or a change to an existing grant's commands. The chief can't shape that
cap (`broker.mjs:126`). 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}`. The raise event body gets `grant = {method, agent, host, commands, commandsSha256, rotateBy, registrySha256,
`registrySha256` hashes the host's registry entry at raise time. If ops-01 re-pins the host between raise and definitionSha256}`. The full `commands` list is stored there, and the executor reads it from that event at
Approve, the executor refuses `decision-mismatch`, and the chief raises again. 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 ### 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. 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 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, 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 when all of these hold: the decision's action is `credential.mint` and its raise carries `grant`, the route
`approvalChoice`, and `via === 'cli'`. 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 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. 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 The executor can't use `#consumeAuthority`, because that needs the raiser's agent session (F4, N1). It gets
(`broker.mjs:808-831`), with `operation: 'host.grant'`, in a transaction before any remote step. A second its own path, `#consumeGrant(business, decisionId, method)`. In one transaction it checks the decision is
start finds it spent and refuses `decision-consumed`. One executor runs at a time per (agent, host), resolved yes by the human via cli, checks `#spent` the same way (`broker.mjs:808-831`), and writes the
through 210's busy map (210: `credentials.mjs:118`). `action.allowed` event with `operation: 'grant.<method>'` 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 ### X2. Steps
1. **Recheck** inside one transaction: the decision is resolved yes by the human via cli and unspent, the Before step 1 the executor claims the (agent, `host.<hostId>`) entry in `#inflight` (X5). A claim that
agent and its definition still declare `host`, the registry entry hashes to `registrySha256`, and no finds an entry refuses `host-busy`, the same as 210's `use` (210: `credentials.mjs:355`).
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. 1. **Recheck and consume** inside one transaction: `#consumeGrant` (X1), the agent and its definition still
2. **Key.** For a new grant, `ssh-keygen -t ed25519 -N '' -C <tag>` in a fresh 0700 dir under declare `host`, the definition hashes to `definitionSha256`, the registry entry hashes to
`<configDir>/secrets/run`, where tag is `mosaic-<business>-<agent>-<hostId>`. For a commands change `registrySha256`, the host is `ready`, the `commands` in the raise event hash to `commandsSha256`, and no
with a live key, skip to step 5. revoke of this (agent, host) is newer than the raise. Any failure is `grant.failed` with its code, and
3. **Install.** Connect as `I` with the installer key, pinned (210's options), and send nothing remote has happened.
`add <user> <type> <base64> <tag>`. If the tag exists on the host, `remove` it first, because an old line 2. **Key.** The tag is `mosaic-` and the first 32 hex digits of sha256 over business, agent and hostId
under the same tag belongs to a key the stack no longer holds. 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 <tag>` in a fresh 0700 dir under `<configDir>/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 <user>`. 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 <user> <type> <base64> <tag>`.
4. **Verify.** Connect as `<user>` with the new key through a short-lived ssh-agent (210 D7) and run `true`. 4. **Verify.** Connect as `<user>` 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 <user> <tag>` The connection proves the line, the `from=` and the pin together. A failure here runs `remove <user> <tag>`
and ends `grant.failed`. and ends `grant.failed`.
5. **Business file.** One write through the grant writer (W1): `roles.<agent>.hosts.<hostId>.commands` set to 5. **Go live.** Write `grant.step {..., step: 'commit', previous}`, where `previous` is the grant's
the decided list, and for a new key `roles.<agent>.credentials["host.<hostId>"] = {"store": true, `commands` before this decision, or `null` for a new key. Then check the abort signal (X5), and with no
"rotateBy": D}`. `await` between, run 210 `issue(agent, "host.<hostId>", privateKey, rotateBy, commit)`. For a new key the
6. **Store.** 210 `issue(agent, "host.<hostId>", privateKey, rotateBy)` writes the key file and fills reference is registered first (X4). `issue` writes the key file, then calls `commit`, which does one W1
memory. The tmp dir from step 2 is removed in a `finally`. write (`roles.<agent>.hosts.<hostId>.commands` set to the decided list, and for a new key
7. **Event and message.** `grant.applied {decision, agent, host, fingerprint, commandsSha256}` and a message `roles.<agent>.credentials["host.<hostId>"] = {"store": true, "rotateBy": D}`) and applies it to the
to the human and the chief. The private key appears in no event, message or log (210 D8's 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). `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 ### X3. Failure and restart
The steps are ordered so a failure leaves nothing an agent can use: 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 | | 2 | tmp dir | `finally`, and the start sweep |
| 3 | maybe a line with a key nobody holds | `remove <user> <tag>`, best effort | | 3 | maybe a line with a key nobody holds | `remove <user> <tag>`, best effort |
| 4 | a line, no grant | `remove`, best effort | | 4 | a line, no grant | `remove`, best effort |
| 5 | a line, no grant | `remove`, best effort | | 5 before `commit` | a line, a key file, no reference in the file | unlink the key file, unregister (X4), `remove` |
| 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 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: <tag>` to `grant.failed`, and `status` shows it until A best-effort `remove` that fails adds `hostLineLeft: <tag>` to `grant.failed`, and `status` shows it until
`mosaic hosts sweep <hostId>` (human or tester) succeeds. `sweep` runs `list <user>` and removes every `mosaic hosts sweep <hostId>` (human or tester) succeeds. `sweep` runs `list <user>` and removes each line
`mosaic-` line whose tag has no live grant. 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, **Restart.** A bus child that dies mid-executor leaves a spent decision with no `grant.applied` or
the bus records `grant.failed {code: 'interrupted'}` for each such decision and runs no remote step. Jason `grant.failed`. At start, before `Credentials` and the broker load, the bus reconciles each one from its last
gets a message. The chief raises again, and Jason approves again. I chose a new decision over an automatic `grant.step` (or `host.enroll.step`), back to the state before the decision (B3):
retry because Jason approved one attempt at one moment, and a retry after a restart might be hours later.
| 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 <user> <tag>` |
| `commit`, new key | unlink the key file if present, W1 drops the grant and the reference if present | `remove <user> <tag>` |
| `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:
<tag>` 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.<hostId>` and `roles.<agent>.hosts.<hostId>`. 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.<id>` 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 ### W1. The grant writer
A second writer on the same lock, revision, tmp, rename and receipt steps as `organizationWriter.write` A second writer on the same lock, revision, tmp, rename and receipt steps as `organizationWriter.write`
(F6), with its own key set: (F6), with its own key set:
- `hosts.<hostId>`, written by `hosts.enroll` and `hosts.remove` only - `hosts.<hostId>`, written by `hosts.enroll` (the executor) and `hosts.remove` only
- `roles.<agent>.hosts.<hostId>` and `roles.<agent>.credentials["host.<hostId>"]`, written by the executor - `roles.<agent>.hosts.<hostId>` and `roles.<agent>.credentials["host.<hostId>"]`, written by the executor
and by revoke only and by revoke only
The receipt's actor is `grant:<decisionId>`, `revoke:<by>` or `enroll:human`. No broker verb takes these The receipt's actor is `grant:<decisionId>`, `enroll:<decisionId>`, `revoke:<by>`, `remove:<by>` or
keys from a request body. `organization.set`, `limits.set` and `instances.set` keep refusing `hosts`, which `reconcile:<decisionId>`. No broker verb takes these keys from a request body. `organization.set`,
210 T3 already tests. `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, 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 ## 6. Revoke
Q188: instant, by Jason, Mos or the tester. `mosaic hosts revoke <agent> <hostId> --reason R`, with Q188: instant, by Jason, Mos or the tester. `mosaic hosts revoke <agent> <hostId> --reason R`, with
`--tester` for the tester (210 D9 already gives the tester `revoke`). Mos revokes as the tester or through `--tester` for the tester (210 D9 already gives the tester `revoke`). Mos revokes as the tester with
Jason's terminal. Q-M1 asks Mos which. `--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: 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 1. 210 `revoke`: memory, abort the `host.run` or the executor in flight (X5), unlink the key file. The call
file. The call answers `credential-unavailable`. This is the "instant" part, and it doesn't touch the answers `credential-unavailable`. This is the "instant" part, and it doesn't touch the network.
network. 2. The grant writer drops `roles.<agent>.hosts.<hostId>`, and `applyGrant` drops it from the broker (X4).
2. The grant writer drops `roles.<agent>.hosts.<hostId>`. The credential reference stays as a store The credential reference stays as a store reference with no file, which reads as revoked (199). A new
reference with no file, which reads as revoked (199). A new grant needs a new decision. grant needs a new decision.
3. `remove <user> <tag>` on the host through the installer. If the host can't be reached, the event is 3. `remove <user> <tag>` on the host through the installer. If the host can't be reached, the event is
`grant.revoked {hostLineLeft: <tag>}`, and `sweep` clears it later. `grant.revoked {hostLineLeft: <tag>}`, 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 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. 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 `hosts remove` (E4) refuses while grants exist or a line is left. Revoking the installer itself is
deleting the line. `hosts remove` and ops-01 deleting the line.
## 7. Row 210's per-call decision becomes manual mode ## 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.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 - 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). 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 - Manual mode (Q188) puts the per-call decision back. Honker's REQ-TRUST-2 takes the list form: the
through the grant writer, `"manual": ["host.sudo"]`, a list of routed actions that classify `gated` for organization file holds a list of actions in manual mode, empty by default, set only by the user. While
every role while listed. `#classification` checks it first (`broker.mjs:597-609`). A list rather than a an action is listed, it classifies `gated` for every role. `#classification` checks the list first
flag, so Jason can make host commands manual without making every routed action manual. (`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 Manual mode is built from REQ-TRUST-2, not in the grant rows (sage-1527 Q-S3).
draft and don't build it in the grant rows. Q-S3 asks Sage where it belongs.
## 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, Honker's PRD 1.8 draft (honker/row194-b2 3c30f1036) carries this design as REQ-GRANT-1 to -4, generalized to
> installs it on the host, and records the grant. No agent writes a grant or holds a host key's private half all three methods. There is no REQ-CRED-5. Duckworth's PRD review (duckworth-0057) notes that this design
> outside the store. A grant names an agent, a host and exact argv prefixes. Revoke takes effect at once on uses `credential.mint` with the method in the `grant` body, not a new `host.grant` action. Points r2 sends
> the stack's side, and the host line is removed when the host can be reached. 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 ## 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 - **The installer key is root on a host with a privileged user.** It can add a line for a user in
`<configDir>/secrets`, readable by the stack's OS user like every other key (199 D6). `mosaic-keys` limits `privileged`, and that user has sudo. It sits in `<configDir>/secrets`, readable by the stack's OS user
which users it can touch, not what those users can do. 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 - **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, 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). 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). - **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 - **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. 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) ## 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 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 | | 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` | | 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 | | 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 | | 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 | `host.grant` classifies gated for every role and routes to `human`, and a tester thread can't raise it | | 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 | | 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` | | 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 | | 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` | | G10 | a second start for the same decision refuses `decision-consumed`, and `#consumeGrant` refuses an unresolved or agent-resolved decision |
| G11 | a bus restart mid-executor gives `grant.failed interrupted` and runs no remote step | | 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 | | 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` | | 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` | | 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) | | 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 | | 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 | `hosts enroll` refuses loopback and this machine's names and bad key lines, and prints no private material | | 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` and runs nothing | | 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: Mutants, a first list. The row's BUILD.md numbers them and adds more:
- drop the chief check - 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 - drop the definition check
- skip the commands hash compare - skip the commands hash compare
- skip the registry hash compare - skip the registry hash compare
- swap the order of install and the business write - swap the order of install and the business write
- skip `remove` on a verify failure - skip `remove` on a verify failure
- skip the start sweep - skip the start sweep
- drop `from=` in `mosaic-keys` - skip the reconcile's remote `remove`
- let `mosaic-keys` touch an unprefixed line - 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 - run revoke's host step before the local steps
- start the executor on an arbiter resolve - start the executor on an arbiter resolve
- start it on `no` - 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 ## 11. Not in this draft
- Gitea and GitHub grants. The same flow: chief raise, Jason approves, the executor mints a token through - Gitea and GitHub grants (`method` `gitea` and `github`). The same flow: chief raise, Jason approves, the
an admin credential Jason enters, writes the reference, and revoke deletes the token at the service. executor mints a token through an admin credential Jason enters, writes the reference, and revoke deletes
The admin scope is refused today (`vocabulary.mjs:105-106`), so that is a design of its own. 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 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). - 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 ## 12. Questions
- **Q-S1 (Sage).** Is "message the chief, and the definition gates the raise" enough for "up the chain" Answered since r1:
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-S1 (sage-1527): no `grant.ask` in row a. Messaging the chief, with the definition gating the raise, is
- **Q-S2 (Sage, Mos).** Where should installer keys live: `secrets/hosts/<business>/<hostId>/installer` enough. If review finds a forgery path, the verb comes then. Duckworth agrees (N4).
(my proposal) or under `secrets/mosaic-stack/`? - Q-S2 (mos-1120, sage-1527): `<configDir>/secrets/hosts/<business>/<hostId>/installer`.
- **Q-S3 (Sage).** Does manual mode (section 7) go into Honker's PRD 1.8 draft beside REQ-CRED-4, and is - Q-S3 (sage-1527): manual mode is Honker's REQ-TRUST-2, and REQ-CRED-5 is folded into REQ-GRANT-1 to -4.
REQ-CRED-5 (section 8) one line Sage carries with it? - Q-S4 (sage-1527): the split stands, both rows are mine, and Duckworth reviews both.
- **Q-S4 (Sage).** My suggested split, yours to size. Row a: enrollment, `mosaic-keys`, the grant writer, - Row a: enrollment as the approved request, the four `tools/host` files, the grant writer, `applyGrant`
and `hosts status`. Row b: the `host.grant` action, the raise binding, the executor and revoke. Row b and `hosts status`, about 1 day.
waits on row a and on 210's landing. Both need the PRD line before landing, as 210 did. - Row b: the raise binding, the executor, reconcile and revoke, after row a, about 1.5 days.
- **Q-M1 (Mos).** How does Mos revoke: as the tester (`--tester`) or from Jason's terminal? - Both come after row 210 lands and need the 1.8 line before landing. Target: in review by 10-24.
- **Q-O1 (ops-01, through Mos).** Is a root-owned forced-command script with a host-side user list and - Q-M1 (mos-1120): Mos revokes as the tester, with `--tester`.
`from=` file acceptable on the hosts? If ops-01 would rather run something else on the host, the - Q-O1 (ops-01 through mos-1125): yes, with the six changes now in E3.
stack-side design stays the same as long as the three requests in E3 behave as stated.
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.