row 210 (#1707): DESIGN.md r2, sage-1450 rulings (argv allowlist only, 1.7 hard wait, 1.8 proposal) and duckworth-0042 B1-B4 with notes, Q-J0 for Jason (Gizmoduck)

This commit is contained in:
2026-10-20 15:36:54 -05:00
parent 7b10bc0fff
commit b726e10f44
+153 -60
View File
@@ -1,12 +1,25 @@
# Row 210 (#424, Gitea #1707): agent host access with sudo through the credential store
Gizmoduck, 2026-10-20, r1. Design only, no code. Reviewer Duckworth, gate owner Sage. Due 2026-10-27 (Q184).
Gizmoduck, 2026-10-20, r2. 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 on `megavolt/row199`.
Row 199 has no code on origin yet, so every "199 gives" below cites its design, not code.
- 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).
- Branch `gizmoduck/row210`. The row can't be claimed yet: `queue move 210 in-progress` refuses "waits on 199".
- 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:
- 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.
The seat network gap is a known limit with what closes it (section 6). Cites now point at 199's code.
- r2 (duckworth-0042, REVIEW-r1.md at 8140b7f39): B1 events name host, user and argv in clear (D5 step 6,
T22, M30). B2 a gated call's question shows stdin in full (D4, T9, M31). B3 the key and processes die with
the bus, and bus start sweeps leftovers (D7, T27, M32 to M34). B4 Q-J0 for Jason in section 10, worded to
stand alone (mos-1088). Notes taken: N1 (E12, runGit's kill shape), N2 (loopback refused, own-host limit),
N3 (process groups), N4 (stdin can make a prefix root), N5 (`-l` and a separate address), N6 (D8 framing),
N7 (section 7).
## 1. What the code does today
@@ -23,10 +36,12 @@ Gizmoduck, 2026-10-20, r1. Design only, no code. Reviewer Duckworth, gate owner
| E9 | The chief's `organization.set` takes only `ORGANIZATION_KEYS`. The human-only `instances.set` takes `INSTANCE_KEYS` and refuses any definition that declares a credential (`organization-credentials-out-of-scope`). Holder, credentials and tracker stay hand edits | `bus/src/broker.mjs:145-147`, `:1248-1298`, `business/src/instance-settings.mjs:1-5`, `:22-31` |
| E10 | An action outside ROUTED is a hard wait and classifies `gated` for every role. No role file may grant one | `bus/src/broker.mjs:44-70`, `business/src/vocabulary.mjs:58-88`, PRD REQ-WAIT-2 and 3 |
| E11 | The seat sandbox does not confine the network, abstract Unix sockets or the host UID | `seat/src/sandbox.mjs:29-31` |
| E12 | The bus child spawns no process today. `child_process` appears only in the human and tester CLIs | `grep child_process packages/bus/src/*.mjs` |
| E12 | On refactor the bus child spawns nothing. With 199 it spawns git: `publishRemote` (`bus/src/runtime.mjs:245` at addee664a) runs `runGit`, a detached group leader that gets SIGTERM at the limit, then SIGKILL to the group after a grace period | `seat/src/checkout.mjs:95-138` at addee664a |
| E13 | Role networks are `none`, `api-only`, `open`. The bundle carries the value, and the sandbox's own comment says the network is not confined (E11) | `business/src/vocabulary.mjs:101`, `harness/src/bundle.mjs:252` |
Row 199 (r6) gives: the store at `<configDir>/secrets/agents/<business>/<agent>/<kind>` (D1), the `{store: true}`
Row 199 gives, in code at addee664a: `useFor` (`bus/src/credentials.mjs:228`), `issue` (`:156`), `revoke`
(`:197`), `remove` (`:211`), the kind list (`:49`), the token pattern (`:19`, `:143`), the store dir's file
regex `^(gitea|vikunja)(\.tmp)?$` (`:222`) and `withGitea` (`bus/src/runtime.mjs:233`). From its design: the store at `<configDir>/secrets/agents/<business>/<agent>/<kind>` (D1), the `{store: true}`
reference (D2), the read path (D3, D3a), `useFor(session, kind, op)` with the call-site allowlist test (D5),
the `publish` grant shape, a list on the role entry plus a hard-wait action whose decision binds the commit
(D9), and the human-only lifecycle verbs with `changes.jsonl` receipts (section 6).
@@ -57,7 +72,8 @@ One key per (agent, host). The kind name is `host.<hostId>`, so the store file i
The closed kind set becomes `gitea`, `vikunja` and the family `host.<hostId>`. One exported helper,
`credentialKind(name)` in `vocabulary.mjs`, answers whether a name is a kind, and each place that checks
kinds calls it: `SERVICES` users (`role.mjs:105-118`), `bus/src/credentials.mjs:22`, the broker's service
kinds calls it: `SERVICES` users (`role.mjs:105-118`), 199's `bus/src/credentials.mjs:49` and the store
file regex at `:222`, the broker's service
check, the CLI `issue` date flag and 199's remove regex. A build test greps for literal `'gitea', 'vikunja'`
lists outside `vocabulary.mjs` and fails on a new one.
@@ -99,11 +115,11 @@ 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])?$`.
`port` is 1 to 65535. `user` matches `^[a-z_][a-z0-9_-]{0,31}$`. `hostKeys` is a non-empty list of
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
`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`:
```
"hosts": { "<hostId>": { "commands": "any" | [["systemctl", "status"], ["journalctl", "-u"]] } }
"hosts": { "<hostId>": { "commands": [["systemctl", "status"], ["journalctl", "-u"]] } }
```
Each `hostId` must be in the registry, and the role's definition must declare `host`. A role entry with
`hosts` whose definition lacks `host` refuses at load and names `roles.<agent>.hosts`. `commands` is the
@@ -118,12 +134,17 @@ declares credentials (E9), and 199's `issue` is human-only.
### D4. The action and the decision binding
`host.sudo` is a new action in ACTIONS and in neither ROUTED list. Until a PRD version routes it, it is a hard
wait and classifies `gated` for every role (E10). Each call then needs a decision the human resolved.
wait and classifies `gated` for every role (E10). Each call then needs a decision the human resolved. PRD 1.7
is frozen (sage-1450, decision d52febb5 cites its sha256), so under 1.7 Jason decides every `host.run`.
The decision binds one call, the way 199 D9 binds `pushCommit`. When the action is `host.sudo`, the
`decision.raise` event body gets `hostRun = {host, argvSha256, stdinSha256}`, from the raise's own args. The
question shown to the human ends with the host id and the argv, each element JSON-quoted, so Jason sees what
runs. `#authorityFor` refuses `decision-mismatch` when the call's host, argv hash or stdin hash differs, as
question shown to the human ends with the host id, the user, the argv with each element JSON-quoted, and the
stdin in full as one JSON string, so Jason sees all of what runs (B2). The broker appends that text itself, and
the agent can't shape it. The whole question keeps the 4096-character cap (`broker.mjs:126`, `:1654`), so on a
gated call stdin is as large as the cap allows and no larger. A raise that doesn't fit refuses
`invalid-request`. `argv ["bash"]` can't be granted at all (D6), and `["tee", "/etc/x"]` with a stdin Jason
can't see is ruled out by showing it. `#authorityFor` refuses `decision-mismatch` when the call's host, argv hash or stdin hash differs, as
`holderRun` does at `broker.mjs:796`. A decision is spent once (`decision-consumed`).
After routing, a definition that lists `host.sudo` within-role runs without a decision, and `decision` is
@@ -139,9 +160,11 @@ host.run {"host", "argv": [..], "stdin"?, "timeout"?, "decision"?}
It runs in the bus child, shaped like `requestWeb` (E6), not as a host IPC op. A sudo command can take
minutes and the host channel times out at 10 s and serves one request at a time (E5). The key is in the bus
child's memory already (199 D3), so no ticket has to cross to the CLI host. Row 199's publish needs a ticket
because git and the working repo live in the CLI host. ssh needs neither. If Megavolt's 199 BUILD.md
deviation moved the executor somewhere this conflicts with, I follow it and record the change. I haven't seen
it, since it isn't on origin.
because git and the working repo live in the CLI host. ssh needs neither. 199's BUILD.md deviation 11
(addee664a) keeps credential use in the bus child too: the push runs there under `withGitea`, and the host
socket only checks and polls. Sage confirmed D5 matches that shape (sage-1450). `host.run` needs no host
socket and no poll, because the agent calls `broker.sock` directly. The helper is `withHost(session, host,
fn)`, beside `withGitea`, calling `useFor(session, "host." + host, fn)`.
Order of checks. Each refusal goes through `broker.refuse()` and logs `action.refused`.
@@ -151,18 +174,23 @@ Order of checks. Each refusal goes through `broker.refuse()` and logs `action.re
that agent and that host".
4. `argv` shape (D6) and `commands` match (`command-not-granted`).
5. `#checkAuthority(s, "host.sudo", {decision, target: host, context})` (D4).
6. Record `action.allowed` phase `requested` with host, argv hash, stdin size and hash. Then `onAdmit()`, which
6. Record `action.allowed` phase `requested` with host, address, user and argv in clear (B1), and stdin as
size and sha256. Every arg already passed `#secretCheck` in step 1, as the web verbs log their url
(`broker.mjs:1521`). After routing, the event log is the record of what ran as root. Then `onAdmit()`, which
sets the socket timeout to the call's timeout plus 10 s, as the web branch does (`server.mjs:95`).
7. `credentials.useFor(s, "host." + host, run)`, where `run` is D7.
8. D8's leak check on the output, then `#secretCheck` on the result.
9. Record phase `completed` with exit, signal, ms, and size and sha256 of stdout and stderr. The output itself
never goes into an event. A failure records `host-failed` with the same fields.
9. Record phase `completed` with host, argv, exit, signal, ms, and size and sha256 of stdout and stderr. The
output itself never goes into an event. A failure records `host-failed` with the same fields.
Only one `host.run` per (agent, host) runs at a time. A second refuses `host-busy`. That keeps a stuck call
from piling up ssh processes and makes revoke (D9) simple.
This is the first process the bus child spawns (E12). The spawn uses `spawn` with an argv array, never a
shell, `detached: true` for its own process group, and `env` set to `{PATH: "/usr/bin:/bin"}` only.
Spawns reuse `runGit`'s shape (E12): an argv array, never a shell, `detached: true` so each process leads its
own group, SIGTERM to the group at the limit and SIGKILL after a grace period. If the helper can be shared, it
is, rather than a second copy. `env` is `{PATH: "/usr/bin:/bin"}` only, plus `SSH_AUTH_SOCK` for `ssh-add`.
Each spawn is wrapped in `setpriv --pdeathsig KILL --`, the prefix the seat sandbox already uses
(`seat/src/sandbox.mjs:44`), so it dies when the bus child dies (B3).
### D6. The command boundary
@@ -181,17 +209,24 @@ remote shell gets each element back exactly (section 2). `sudo -n` fails instead
without NOPASSWD returns sudo's error, not a hang. The host's login shell for `user` must be POSIX. That is a
host precondition for ops-01, listed in section 7.
`commands` in the grant:
`commands` in the grant is an argv allowlist and nothing else. It is a non-empty list of argv prefixes. An
argv passes when its first N elements equal some prefix of length N, compared element by element as literal
strings, with no globbing. `[["systemctl", "status"]]` admits `systemctl status nginx` and refuses
`systemctl restart nginx`.
- `"any"`: every argv passes. This is root on the host.
- a list of argv prefixes: an argv passes when its first N elements equal some prefix of length N, element
by element, no globbing. `[["systemctl", "status"]]` admits `systemctl status nginx` and refuses
`systemctl restart nginx`.
There is no wildcard grant (sage-1450). Business-file load refuses, naming `roles.<agent>.hosts.<id>.commands`:
I recommend Jason uses prefix lists where the job is known and `"any"` only for an agent he'd trust with root
on that host. A prefix list limits mistakes, not a determined agent. Many commands can start a shell or an editor as
root, `systemctl edit` for one. Section 6 says
plainly what `"any"` gives away.
- 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".
That last list is not complete. 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.
Output: stdout and stderr each capped at 1 MiB. Past the cap the bus keeps reading and drops the rest so the
remote side doesn't block, and sets `truncated`. `timeout` defaults to 60 s, maximum 900 s. At the timeout
@@ -208,9 +243,11 @@ 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. Start `ssh-agent -D -a <dir>/a.sock` as a child in the same process group, wait for the socket, then run
`ssh-add -` with `SSH_AUTH_SOCK` set and the private key written to its stdin. The key never touches disk,
argv or env.
3. Write `<dir>/pids` with the bus child's pid. 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
is appended too. Timeout, revoke and the `finally` kill both groups, ssh-agent's and ssh's (N3).
4. Run ssh with exactly these options and nothing from any config file:
```
ssh -F /dev/null -T -p <port>
@@ -220,21 +257,30 @@ Per call, inside `useFor`'s operation:
-o ForwardAgent=no -o ForwardX11=no -o ClearAllForwardings=yes -o PermitLocalCommand=no
-o ProxyCommand=none -o ProxyJump=none -o ControlMaster=no -o ControlPath=none
-o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=3
<user>@<address> -- <remote command from D6>
-l <user> -- <address> <remote command from D6>
```
`-F /dev/null` stops `~/.ssh/config` from adding a ProxyCommand, a different key or forwarding. The pin is
The user goes in `-l` and the address is its own argument, so nothing depends on how ssh splits `@` or on
an IPv6 literal (N5). `-F /dev/null` stops `~/.ssh/config` from adding a ProxyCommand, a different key or forwarding. The pin is
`StrictHostKeyChecking=yes` against a file the bus wrote from the registry, so there is no trust on first
use: an unknown or changed host key refuses with rc 255 (probe, section 2) and the bus answers
`host-key-mismatch` when stderr has "Host key verification failed".
5. In a `finally`: kill the agent, kill any ssh still running, remove the dir. A test asserts the dir is gone
after success, refusal, timeout and an exception thrown inside the operation.
5. In a `finally`: kill both groups, remove the dir. A test asserts the dir is gone after success, refusal,
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
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,
and the fd route doesn't work (probe).
### D8. The leak check
`assertClean` misses a multi-line key (E3). For a `host.*` entry, `useFor` checks the result with
This is defense in depth (N6). The remote never holds the private key and forwarding is off, so output can
carry the key only if the agent already had it. `assertClean` misses a multi-line key (E3). A `host.*` entry
in the shared token map makes its loop compare against a value that never matches, which is harmless. For a `host.*` entry, `useFor` checks the result with
`assertCleanKey(result, entry)`, which searches the JSON text for:
- each base64 body line of the PEM of 24 characters or more,
@@ -303,26 +349,44 @@ that way.
REQ-CRED-3 says the who-grants answer is set by this design and approved by Jason. The table above is that
answer.
## 5. The routing gap
## 5. The routing gap and a proposed 1.8 requirement
PRD 1.7 (draft r2) routes `role.hire`, `role.fire` and `git.push.remote`. It does not route `host.sudo`. Under
REQ-WAIT-3 the action lands as a hard wait, so each `host.run` needs a decision Jason resolves, bound to that
host and argv (D4). That is safe but probably not what Q183 asks for ("allow agents to perform their jobs").
PRD 1.7 routes `role.hire`, `role.fire` and `git.push.remote`, not `host.sudo`, and 1.7 is frozen (sage-1450).
This row builds for 1.7 as it stands. `host.sudo` is a hard wait under REQ-WAIT-3, and Jason decides each
`host.run`, bound to host, argv and stdin (D4). That is safe, and slower than Q183's "allow agents to perform
their jobs". The routing goes in 1.8. Sage takes the line below to Mos for Jason. I don't edit the PRD.
Q-S1 for Sage: can `host.sudo` routing go into 1.7 beside REQ-CRED-3, or does it wait for 1.8? The code is the
same either way. Routing is one reviewed commit adding it to both ROUTED lists and to the chosen definitions'
`withinRole`, as 199 plans for `git.push.remote`.
Proposed for PRD 1.8, beside REQ-CRED-3:
> **REQ-CRED-4.** The action `host.sudo` is routed. A role whose definition lists it within-role may run a
> command with sudo on a host without a decision for each call, but only when all of these hold: the
> definition declares the host credential kind, the business file grants that agent that host, the command
> matches the grant's argv allowlist, and the agent's key for that host is issued. A grant names exact
> argv prefixes. A wildcard grant is refused. A role without `host.sudo` within-role still needs a decision
> for each call. Jason adds `host.sudo` to a definition only through a reviewed role change.
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.
## 6. Limits, said plainly
- **`"any"` is root, and root can outlive the grant.** The seat sandbox doesn't confine the network (E11).
An agent with `"any"` on a host can add its own key to `authorized_keys` there, then ssh straight from its
sandbox, around the bus, the events and revoke. Prefix lists make that harder, not impossible. Only a
sandbox that confines the network closes it, and that is not this row. So a grant of `"any"` should go only
to an agent Jason would trust with root on that host, and ops-01's host-side audit (sudo log,
`authorized_keys` changes) is the check after the fact.
- **Known limit: the seat network isn't confined, so root can outlive the grant.** `seat/src/sandbox.mjs:29-31`
says the sandbox doesn't confine the network (E11). An agent whose allowlist admits any command that can
write a file as root, or start a shell, can add its own key to `authorized_keys` on the host. It can then
ssh straight from its sandbox, around the bus, the events and revoke. An `authorized_keys` `from=` option
doesn't help, because the sandbox shares the stack host's address. The allowlist (D6) narrows this but
doesn't close it.
What closes it: a seat sandbox with its own network namespace (`bwrap --unshare-net`), where the only way
out is the bus sockets and the model proxy, so role network `none` and `api-only` are enforced and not just
carried in the bundle (E13). With that in place, a planted key is useless from the sandbox. Until then,
ops-01's host-side audit (sudo log, `authorized_keys` changes) is the check after the fact. Sage is
reporting the gap to Mos as its own follow-up (sage-1450). It is not this row.
- **The stack's own machine.** A registry entry for the machine the stack runs on, or any machine holding the
stack's `<configDir>`, gives an agent with a root-writing command every agent's keys and tokens, not only
root there (N2). Load refuses loopback addresses, but it can't know every address of the stack's machine.
Jason doesn't register it.
- **Revoke stops the stack's path, not remote processes.** Killing ssh may leave a remote process running.
`-T` gives no tty, so there is no SIGHUP from a closing terminal.
- **Same OS user.** Anything running as the stack's OS user outside a sandbox can read the key file, as 199 D6
@@ -336,6 +400,9 @@ The brief also says this row lands only after the PRD version holding REQ-CRED-3
- `user` has NOPASSWD sudo and a POSIX login shell. `sudo` has no `requiretty` for that user.
- The agent's key is in `authorized_keys` with `from="<stack host>",restrict`.
- The host's public keys are handed to Jason for the registry.
- Duckworth's #423 r5 condition (duckworth-0035) applies. If ops-01 changes #423 step 1 to fit this design,
for example per-agent `from=...,restrict` lines for localadmin, that change goes to Duckworth as a diff
before it runs. That is the live part, not this row (N7).
## 8. Tests
@@ -347,15 +414,15 @@ another host.
| # | Test | Refusal or result |
|---|---|---|
| T1 | business load: `hosts` registry field checks, bad address, 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 loopback form, 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` |
| T5 | `host.run` with an unknown host | `host-unknown` |
| T6 | `host.run` on a host not in the caller's grant, including a host granted to another agent | `host-not-granted` |
| T7 | argv shape: empty, a NUL, too long, too many, a relative path with `/`, non-string | `invalid-request` |
| T8 | prefix list: a matching argv runs, a longer argv runs, a shorter one and a differing element refuse | `command-not-granted` |
| T9 | before routing: no decision refuses, a decision for another argv, another host or another stdin refuses, a spent one refuses | `gated`, `decision-mismatch`, `decision-consumed` |
| T8 | allowlist: a matching argv runs, a longer argv runs, a shorter one and a differing element refuse | `command-not-granted` |
| T9 | before routing: no decision refuses, a decision for another argv, another host or another stdin refuses, a spent one refuses. The stored question carries host, user, argv and the full stdin, and a raise whose question would pass 4096 characters refuses | `gated`, `decision-mismatch`, `decision-consumed`, `invalid-request` |
| T10 | quoting: argv with spaces, quotes, `$()`, backticks, `;`, newline and glob characters reaches fake sudo unchanged | fake sudo's JSON equals argv |
| T11 | the remote command starts `sudo -n --` | fake sudo sees `-n` and `--` first |
| T12 | exit code and signal pass through, timeout kills the process group and answers `signal` | result fields |
@@ -368,10 +435,12 @@ another host.
| T19 | the private key never appears in the argv or env of any spawned process (read from `/proc/<pid>/cmdline` and `environ` while the fake sudo sleeps) | absent |
| T20 | second concurrent call for the same agent and host | `host-busy` |
| T21 | revoke during a sleeping call kills it, and the next call refuses | `credential-unavailable` |
| T22 | events: `action.allowed` requested and completed carry sizes and hashes, never stdout, stderr, stdin or the key | event bodies |
| T22 | events: `action.allowed` requested and completed carry host, address, user and argv in clear, and sizes and hashes of stdin, stdout and stderr. Never the output, the stdin or the key | event bodies |
| 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 |
| 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
@@ -384,7 +453,7 @@ One per refusal, plus the two the brief names. Each must be killed.
| M3 | grant check reads any agent's `hosts` | T6 |
| M4 | each argv shape check removed, one mutant each (5) | T7 |
| M5 | prefix match compares only `argv[0]` | T8 |
| M6 | `"any"` assumed for a list grant | T8 |
| M6 | load accepts a string `commands` (wildcard grant) | T26 |
| M7 | `#checkAuthority` call removed | T9 |
| M8 | `hostRun` argv hash not compared | T9 |
| M9 | `hostRun` stdin hash not compared | T9 |
@@ -406,6 +475,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 |
| 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 |
| 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
@@ -413,8 +490,24 @@ slot.
## 10. Questions
- **Q-S1 (Sage):** routing for `host.sudo` in 1.7 or 1.8 (section 5).
- **Q-S2 (Sage, for Megavolt):** does 199's BUILD.md deviation move credential use out of the bus child? D5
assumes the key stays there.
- **Q-J1 (Jason, through Mos, at the live window):** the first grants, per agent and host, `"any"` or a prefix
list. Not needed for the build.
- Q-S1, answered (sage-1450): 1.7 is frozen. Hard wait under 1.7, and section 5 holds the 1.8 proposal.
- 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:
> Q-J0 (#424). Who may give an agent sudo on another machine?
> 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.
> 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.