row 210 (#1707): DESIGN.md r3, duckworth-0043 C1 (Q-J0 revoke line matches section 6), N1-N4, sage-1468 scope line (harness tool out, Q-S3) and 1.7 approval (Gizmoduck)

This commit is contained in:
2026-10-20 15:41:07 -05:00
parent b726e10f44
commit e021dfa260
+50 -25
View File
@@ -1,15 +1,27 @@
# Row 210 (#424, Gitea #1707): agent host access with sudo through the credential store
Gizmoduck, 2026-10-20, r2. Design only, no code. Reviewer Duckworth, gate owner Sage. Due 2026-10-27 (Q184).
Gizmoduck, 2026-10-20, r3. Design only, no code. Reviewer Duckworth, gate owner Sage. Due 2026-10-27 (Q184).
- Brief: `docs/plans/2026-10-20_host-credentials.md` at 9db578ff5.
- Base read: `origin/refactor` 9ba31b5b3. Row 199 read as DESIGN.md r6 (4d3007ec9) and its code and
BUILD.md at addee664a on `megavolt/row199`.
- PRD: REQ-CRED-3 in Honker's 1.7 draft (`agents/honker/work/queue-194/PRD-1.7-DRAFT.md:111-115`, 11cb0423b).
Jason approved PRD 1.7 (d52febb5 yes at 20:36:47Z, mos-1089), so REQ-CRED-3 is in.
- Scope (sage-1468): this row ships the `host.run` broker op, the grant check, the executor and the receipts,
all testable over `broker.sock`. The `host_run` harness tool is out, as 199 left its publish tool out
(199 BUILD.md deviation 12). Sage files one follow-up row for the agent-facing harness tools, publish and
`host_run`, in `packages/harness`. Duckworth's r2 N5 (the tool help says a gated call leaves stdin under
4 KiB) belongs to that row.
- Branch `gizmoduck/row210`. Row claimed in-progress (rev 2361) after Sage cleared the after (rev 2359). It
lands only after 199, rebased onto 199's landing (Q177).
Rounds:
- r3 (duckworth-0043, REVIEW-r2.md at 52b5fee63, approved with C1, and sage-1468, sage-1472): C1 Q-J0's revoke
line now matches section 6, the connection is cut and a started command may keep running. Q-S3 answered,
the harness tool is out (scope line above, D10). N1 the runner list takes Duckworth's whole list, and D6
says the per-call decision is the control. N2 numeric shorthand, unspecified and mapped addresses and
`*.localhost` refuse (D3, T1). N3 the pids file records each pid's start time (D7, T27). N4 the mutant
table is in order.
- r2 (sage-1450): no `"any"` grant. The command boundary is an argv allowlist per host grant, and load refuses
a wildcard (D6, T26, M6, M28). PRD 1.7 is frozen, so `host.sudo` stays a hard wait and Jason decides each
call. Section 5 now holds the proposed 1.8 routing requirement. D5 is confirmed against 199's deviation 11.
@@ -115,7 +127,12 @@ Four layers. Each must hold, and the code checks all four on every call.
"hostKeys": ["ssh-ed25519 AAAA..."] } }
```
`address` is an IPv4 or IPv6 literal or a DNS name matching `^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$`.
A loopback address (`127.0.0.0/8`, `::1`, `localhost`) refuses at load (N2). `port` is 1 to 65535. `user` matches `^[a-z_][a-z0-9_-]{0,31}$`. `hostKeys` is a non-empty list of
An address of this machine refuses at load (r2 N2, r3 N2). For an IP literal, which `net.isIP` decides,
that is `127.0.0.0/8`, `0.0.0.0/8`, `::1`, `::`, and an IPv4-mapped `::ffff:` form of either IPv4 range.
For a name, it is `localhost` and any name ending in `.localhost`. A name made only of digits and dots
refuses unless `net.isIP` reads it as IPv4, so the shorthand `127.1` and the integer `2130706433`, which
pass the DNS pattern and which the resolver reads as 127.0.0.1, refuse. A label starting `0x` refuses for
the same reason. `port` is 1 to 65535. `user` matches `^[a-z_][a-z0-9_-]{0,31}$`. `hostKeys` is a non-empty list of
`ssh-ed25519`, `ecdsa-sha2-nistp256` or `ssh-rsa` public key lines, type and base64 only, no comment.
3. **Agent grant, business file, hand edit.** A new role-entry key beside 199's `publish`:
```
@@ -219,11 +236,15 @@ There is no wildcard grant (sage-1450). Business-file load refuses, naming `role
- any value that isn't a list, including the strings `"any"` and `"*"`,
- an empty list, an empty prefix, a non-string element, or a duplicate prefix,
- an element containing `*`, `?` or `[`, so nobody writes a glob that the matcher would read literally,
- a prefix whose first element, by basename, is a shell or a command runner: `sh`, `bash`, `dash`, `zsh`,
`ksh`, `fish`, `su`, `sudo`, `env`, `xargs`, `nohup`, `setsid`, `timeout`, `nice`, `busybox`. Each of these
would turn the prefix back into "any".
- a prefix whose first element, by basename, is a shell, a command runner or an interpreter. Shells: `sh`,
`bash`, `dash`, `zsh`, `ksh`, `fish`, `busybox`. Runners: `su`, `sudo`, `doas`, `pkexec`, `runuser`, `env`,
`xargs`, `nohup`, `setsid`, `timeout`, `nice`, `systemd-run`, `chroot`, `nsenter`, `unshare`, `setpriv`,
`capsh`, `flock`, `stdbuf`, `ionice`, `chrt`, `taskset`, `strace`, `script`, `watch`. Interpreters:
`python3`, `perl`, `ruby`, `node`, `awk` (Sage's ruling on duckworth-0043 N1). Each would turn the prefix
back into "any". T26 has one case per name.
That last list is not complete. Many commands can still start a shell or an editor as root (`systemctl edit`,
That list can't be complete, and it isn't the control. Under 1.7 the control is Jason's decision on each
call, bound to host, argv and stdin (D4, Q-S1). The list only refuses the obvious cases at load, early. Many commands can still start a shell or an editor as root (`systemctl edit`,
`find -exec`, interpreters). The allowlist limits mistakes and narrows what a grant means, but it is not a
sandbox. Stdin goes to the command, so some prefixes are root on their own: `[["tee"]]` or `[["dd"]]` writes
any file as root from stdin (N4). Section 6 says what remains.
@@ -243,7 +264,8 @@ Per call, inside `useFor`'s operation:
`host-failed` if over.
2. Write two files in it, mode 0600: `known_hosts` with one line per pinned key, `mosaic-host-<hostId> <type>
<base64>`, and `id.pub` with the entry's public key (D2).
3. Write `<dir>/pids` with the bus child's pid. Start `ssh-agent -D -a <dir>/a.sock` as its own group leader,
3. Write `<dir>/pids` with the bus child's pid. Each line in it is a pid and that process's start time,
field 22 of `/proc/<pid>/stat`, so a reused pid never matches (duckworth-0043 N3). Start `ssh-agent -D -a <dir>/a.sock` as its own group leader,
append its pid, and wait for the socket. Run `ssh-add -t <timeout + 30> -` with `SSH_AUTH_SOCK` set and the
private key written to its stdin. The key never touches disk, argv or env. `-t` makes ssh-agent drop the key
at the call's timeout plus 30 s even if nothing else runs (B3). ssh is the second group leader, and its pid
@@ -268,9 +290,10 @@ Per call, inside `useFor`'s operation:
timeout and an exception thrown inside the operation.
6. If the bus child is killed, the `finally` never runs. `--pdeathsig` kills ssh-agent and ssh with it, and
`-t` drops the key anyway. At bus start, before `broker.sock` listens, the bus sweeps
`<configDir>/secrets/run/`. For each dir whose recorded bus pid is no longer alive, it sends SIGKILL to each
recorded pid that is still alive and whose `/proc/<pid>/cmdline` names that dir, then removes the dir. A
dir whose bus pid is alive is left alone. Any other entry there refuses start, as 199 refuses a stray file
`<configDir>/secrets/run/`. A recorded pid counts as alive only when it exists and its start time matches.
For each dir whose bus pid is no longer alive, it sends SIGKILL to each recorded pid that is alive and
whose `/proc/<pid>/cmdline` names that dir, then removes the dir. A dir whose bus pid is alive is left
alone. Any other entry there refuses start, as 199 refuses a stray file
in the store (`credentials.mjs:222`). Revoke after a restart has nothing in flight to reach.
The 0600 temp key file route the brief allows is not used. The agent route keeps the private key off disk,
@@ -318,9 +341,10 @@ The tester gets `status` and `revoke` only, as in 199.
| `bus/src/broker.mjs` | `host.run` verb (D5), `host.sudo` in `#authorityFor` with `hostRun` (D4) |
| `bus/src/server.mjs` | `host.run` branch beside the web branch with `onAdmit` |
| `bus/src/business.mjs` | carry `hosts` onto the bus role entry |
| `harness` tool list | a `host_run` tool for roles whose bus entry has hosts, wired the way the web tools are (`harness/src/bundle.mjs:20-32`) |
| `cli` | 199's `credentials issue` accepts `host.<id>`, prints D9's lines |
No `packages/harness` file changes. The `host_run` tool is Sage's follow-up row (sage-1468).
Row 199's own kind and regex are not changed inside row 199. This row rebases onto 199's landing and changes
them here.
@@ -368,7 +392,8 @@ Proposed for PRD 1.8, beside REQ-CRED-3:
Routing is then one reviewed commit: `host.sudo` into both ROUTED lists and into the chosen definitions'
`withinRole`, as 199 does for `git.push.remote`.
The brief also says this row lands only after the PRD version holding REQ-CRED-3 is approved.
The brief also says this row lands only after the PRD version holding REQ-CRED-3 is approved. Jason approved
1.7 (d52febb5 yes at 20:36:47Z, mos-1089), so that holds. 1.7 doesn't route `host.sudo`, so Q-S1 stands.
## 6. Limits, said plainly
@@ -414,7 +439,7 @@ another host.
| # | Test | Refusal or result |
|---|---|---|
| T1 | business load: `hosts` registry field checks, bad address, each loopback form, port, user, empty `hostKeys`, a key line with a comment | load refuses, names the key path |
| T1 | business load: `hosts` registry field checks, bad address, each own-machine form from D3 (`127.0.0.2`, `0.0.0.0`, `::1`, `::`, `::ffff:127.0.0.1`, `::ffff:0.0.0.0`, `localhost`, `a.localhost`, `127.1`, `2130706433`, `0x7f.1`), with `10.1.1.53` and `host.example` as controls, port, user, empty `hostKeys`, a key line with a comment | load refuses, names the key path |
| T2 | role entry `hosts` naming an unknown host, or on a definition without `host` | load refuses `roles.<agent>.hosts` |
| T3 | `organization.set` and `instances.set` with `hosts` in args | `invalid-request`, and the file is unchanged |
| T4 | value check: a valid unencrypted ed25519 key loads. An encrypted key, an RSA key, two keys, CRLF, a gitea token in a `host.*` slot, and a PEM in a `gitea` slot each refuse | `credential-format` |
@@ -439,7 +464,7 @@ another host.
| T23 | `issue` of a `host.*` kind prints the public line and the `authorized_keys` suggestion and never the private key | stdout and stderr checked |
| T24 | build grep: no literal `'gitea', 'vikunja'` kind list outside `vocabulary.mjs`, and 199's `credentials.use(` allowlist unchanged | grep |
| T25 | no role file in `roles/` lists `host.sudo` while it is a hard wait (REQ-WAIT-2) | grep |
| T27 | SIGKILL the bus child while fake sudo sleeps: the ssh and ssh-agent pids are gone within 2 s. A call with `timeout` 1 leaves an agent whose key is gone after 31 s (`ssh-add -l` on its socket, agent kept alive by the test). The next bus start removes the run dir, kills a planted live process whose cmdline names it, and leaves a dir whose bus pid is alive | pids, `ssh-add -l`, dir state |
| T27 | SIGKILL the bus child while fake sudo sleeps: the ssh and ssh-agent pids are gone within 2 s. A call with `timeout` 1 leaves an agent whose key is gone after 31 s (`ssh-add -l` on its socket, agent kept alive by the test). The next bus start removes the run dir, kills a planted live process whose cmdline names it, leaves a dir whose bus pid is alive, and leaves a live process whose pid matches but whose start time doesn't | pids, `ssh-add -l`, dir state |
| T26 | load refuses `commands` as `"any"`, `"*"`, `{}`, `[]`, `[[]]`, a non-string element, a duplicate prefix, an element with `*`, `?` or `[`, and a prefix starting with each listed shell or runner, `/bin/bash` included | load refuses, names `roles.<agent>.hosts.<id>.commands` |
## 9. Mutants
@@ -475,14 +500,14 @@ One per refusal, plus the two the brief names. Each must be killed.
| M25 | each value check removed, one mutant each (encrypted, type, count, CRLF) | T4 |
| M26 | `organization.set` accepts `hosts` | T3 |
| M27 | load check for a definition without `host` removed | T2 |
| M28 | shell and runner check removed, and a second mutant compares `argv[0]` without taking the basename | T26 |
| M29 | glob character check removed | T26 |
| M30 | `requested` event drops argv | T22 |
| M31 | the question omits stdin | T9 |
| M32 | `--pdeathsig` dropped from the spawns | T27 |
| M33 | `ssh-add` without `-t` | T27 |
| M34 | start sweep removed, and a second mutant sweeps without checking the bus pid | T27 |
| M34 | start sweep removed, a second mutant sweeps without checking the bus pid, and a third ignores the start time | T27 |
| M35 | loopback check removed | T1 |
| M28 | shell and runner check removed, and a second mutant compares `argv[0]` without taking the basename | T26 |
| M29 | glob character check removed | T26 |
The mutant script follows row 201's shape: copy the packages to scratch, anchor each mutant once, run each
test file in its own process group with a timeout, control first, exit 1 on any survivor. It runs on Sage's
@@ -491,6 +516,7 @@ slot.
## 10. Questions
- Q-S1, answered (sage-1450): 1.7 is frozen. Hard wait under 1.7, and section 5 holds the 1.8 proposal.
- Q-S1 still holds after 1.7's approval, which doesn't route `host.sudo` (sage-1468).
- Q-S2, answered (sage-1450): 199's deviation 11 keeps credential use in the bus child, so D5 stays.
- **Q-J0 (Jason, through Mos, before landing, B4, mos-1088).** Word for word:
@@ -498,16 +524,15 @@ slot.
> Proposal: only you. No agent can, and that includes the chief of staff and the tester.
> You make a grant in four steps, all yours. A reviewed change to the agent's role allows machine keys at all.
> You list the machine and its fingerprint. You give that agent that machine with an exact list of allowed
> commands. You issue the key, and ops-01 installs it on the machine.
> Until a later PRD says otherwise, you also approve each command before it runs.
> You, Mos or the tester can revoke at once. The stack drops the key and stops a running command.
> A grant of "any" command is root on that machine, so the design refuses it. Some listed commands, such as
> a file writer, still reach root, so grant a machine only to an agent you'd trust as root there.
> commands. You issue the key, and ops-01 installs it. Until a later PRD says otherwise, you also approve
> each command before it runs. You, Mos or the tester can revoke at once. The stack drops the key and cuts
> its connection. A command already started on the machine may keep running. A grant of "any" command is
> root on that machine, so the design refuses it. Some listed commands, such as a file writer, still reach
> root, so grant a machine only to an agent you'd trust as root there.
> Do you approve: only you grant machine access, as above? Yes or no. I recommend yes.
Mos records the answer in DECISIONS.md in jason-master-restart.
- **Q-J1 (Jason, through Mos, at the live window):** the first grants, each an argv allowlist per agent and
host. Not needed for the build.
- **Q-S3 (Sage):** 199 left its harness tool out of scope (BUILD.md deviation 12). D10 includes a `host_run`
harness tool, because without one an agent can't call `host.run`. If Sage wants it as a follow-up instead,
I drop that line from the manifest.
- Q-S3, answered (sage-1468): the `host_run` harness tool is out of this row. Sage files a follow-up row for
publish and `host_run` in `packages/harness` (scope line at the top).