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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user