agents: Gizmoduck machine-grant design draft r1 (Vikunja #436, Q188, mos-1115)
This commit is contained in:
1 parent
f41729178e
commit
f6b6a04b60
1 file changed
+443
@@ -0,0 +1,443 @@
|
||||
# Machine grant path, design draft r1 (Vikunja #436, row 210 follow-up)
|
||||
|
||||
Owner: Gizmoduck. Reviewer: Duckworth. Sizing and row assignment: Sage. Design only, no code until Sage
|
||||
confirms the row and owner (mos-1115).
|
||||
|
||||
Sources: Q188 (DECISIONS.md:1642, jason-master-restart 8ecf252), mos-1115, row 210 DESIGN r3 (e021dfa26) and
|
||||
its approved candidate 455964f23. Code cites are `path:line` at origin/refactor f41729178 unless they say
|
||||
"210:", which means the row 210 candidate 455964f23 (the same code as the branch head a2fd30390).
|
||||
|
||||
## 0. What Q188 asks for, and what this draft builds
|
||||
|
||||
Q188 says no agent creates a grant. A request goes up the chain, only the chief raises it to Jason's inbox,
|
||||
Jason clicks Approve or Deny, and on Approve protected stack code creates the grant. Revoke stays instant, by
|
||||
Jason, Mos or the tester. mos-1115 puts machines first: issue the key, install it on the host with
|
||||
`from="<stack host>",restrict`, pin the host fingerprint, and on revoke drop the key and cut the connection.
|
||||
This replaces row 210 section 4, where Jason hand-edits three layers, runs `issue`, and ops-01 pastes the
|
||||
`authorized_keys` line.
|
||||
|
||||
This draft covers:
|
||||
|
||||
- host enrollment, once per host (section 3)
|
||||
- the grant request and its decision (section 4)
|
||||
- the executor that runs on Approve (section 5)
|
||||
- revoke (section 6)
|
||||
- how row 210's per-call decision becomes manual mode (section 7)
|
||||
|
||||
Gitea and GitHub grants follow the same executor shape and are a later draft (section 11).
|
||||
|
||||
One thing up front, because it changes what Jason sees on 10-27. A grant made this way still doesn't let an
|
||||
agent run a command without a click. Under PRD 1.7 `host.sudo` is a hard wait, and every `host.run` needs its
|
||||
own decision (210 D4). "Runs without a click" needs REQ-CRED-4 in PRD 1.8 (Vikunja #434, Honker through Sage)
|
||||
and the reviewed commit that routes `host.sudo`. This path and that routing are separate pieces, and the
|
||||
first is useless for Q188's goal without the second.
|
||||
|
||||
## 1. Facts from the code
|
||||
|
||||
- F1. **No chain of command exists in the data.** A role definition has only `roleVersion, name, title,
|
||||
contract, tools, network, authority, credentials, messaging, skills` (`business/src/role.mjs:19-20`). There
|
||||
is no `reportsTo` or manager field. The one fixed rank is the chief, the definition named
|
||||
`chief-of-staff` (`bus/src/broker.mjs:157`), and the broker already checks an instance's definition against
|
||||
it (`broker.mjs:593`, `:1990`). Who may message whom is the only structure, `#checkMessaging`
|
||||
(`broker.mjs:617-627`).
|
||||
- F2. **Any agent may raise any action.** `decision.raise` stores `raised_by_role` and `raised_by_run`
|
||||
(`broker.mjs:1703-1708`). No rule limits which role raises which action, and no raise names another agent
|
||||
as its beneficiary.
|
||||
- F3. **A hard wait always routes to the human.** Any action outside `ROUTED` is in `GATED_ONLY`
|
||||
(`business/src/vocabulary.mjs:88`), classifies `gated` (`broker.mjs:597-609`) and routes to `human`
|
||||
(`broker.mjs:1677-1680`). A tester thread can't route to the human (`broker.mjs:1683`).
|
||||
- F4. **Resolving a decision runs nothing.** `decision.resolve` records a `decision_events` row and messages
|
||||
the raiser (`broker.mjs:1752-1828`). An effect happens later, when a verb consumes the decision through
|
||||
`#consumeAuthority`, which refuses `decision-consumed` if an `action.allowed` event other than the raise
|
||||
already cites it (`broker.mjs:808-831`). The landing path is the only place where stack code reacts to a
|
||||
resolution, and even there the agent's own `land_work` call does the work (`cli/src/launcher.mjs:555-600`).
|
||||
- F5. **The human proof.** `mosaic decide` resolves through `human-cli.mjs` (`cli/src/cli.mjs:142-175`,
|
||||
`cli/src/transport.mjs:55-75`). `verifyHuman` is a same-UID cooperative check (`bus/src/human.mjs:50-52`),
|
||||
and `#human` needs `via === 'cli'` and `outsideAgent` (`broker.mjs:375-378`).
|
||||
- F6. **The business file has one writer discipline.** `organizationWriter.write`
|
||||
(`business/src/organization.mjs:177-219`): a lock dir, a revision that is the sha256 of the current bytes,
|
||||
`previous.json`, tmp, fsync, rename, and receipts in `businesses/.history/<id>/changes.jsonl`. The bus child
|
||||
does the write (`cli/src/host.mjs:215`, `bus/src/process.mjs:1-3`). Agent-reachable keys are
|
||||
`ORGANIZATION_KEYS` and `INSTANCE_KEYS` (`broker.mjs:146-148`). Row 210's `hosts` keys sit outside both.
|
||||
- F7. **Row 210's pieces this path reuses:**
|
||||
- the agent key store at `<configDir>/secrets/agents/<business>/<agent>/host.<id>` (210:
|
||||
`bus/src/credentials.mjs:153-157`)
|
||||
- `issue`, which writes tmp, fsync, rename and then calls `commit` (210: `credentials.mjs:243-280`)
|
||||
- `revoke`, which drops memory, aborts the call in flight and unlinks the file (210:
|
||||
`credentials.mjs:284-298`)
|
||||
- pinned `known_hosts` built from the registry's `hostKeys` with `StrictHostKeyChecking=yes` (210:
|
||||
`bus/src/host-run.mjs:36`, `:170`)
|
||||
- F8. **Nothing on refactor generates keys or contacts a host.** No `ssh-keygen` or ssh spawn exists in
|
||||
`packages/*/src`. Row 210 adds the ssh spawn, and row 210's tests already require `ssh-keygen` on the
|
||||
machine (210 test list, first test).
|
||||
- F9. **The Console can't approve today.** It shows decisions and copies the `mosaic decide` command
|
||||
(`webui/src/public/bus.js:3-5`). `decision.resolve` is not in `BUS_VERBS` (`webui/src/serve.mjs:62`).
|
||||
Vikunja #435 adds Approve after Q175 sign-in (#412).
|
||||
- F10. **`credential.mint` is already a hard wait with no executor** (`vocabulary.mjs:38`).
|
||||
|
||||
## 2. Shape
|
||||
|
||||
```
|
||||
agent --message--> chief --decision.raise host.grant--> Jason's inbox
|
||||
|
|
||||
mosaic decide (later: Console Approve, #435)
|
||||
|
|
||||
bus child grant executor (on resolve, approve only)
|
||||
keygen -> install via host installer -> verify -> business file -> key store
|
||||
```
|
||||
|
||||
Revoke runs the other way: memory, then the in-flight call, then the key file, then the host line, then the
|
||||
business file.
|
||||
|
||||
## 3. Host enrollment (once per host, human only)
|
||||
|
||||
Installing a key on a host needs something on the host that already trusts the stack. Nothing on refactor
|
||||
holds that trust, so each host gets one enrollment. This is the step that remains for ops-01, once per
|
||||
host. Per-agent installs no longer need ops-01.
|
||||
|
||||
### E1. What Jason enters
|
||||
|
||||
`mosaic hosts enroll <hostId> --address A --port P --user U --install-user I`, with the host's public keys on
|
||||
stdin, one `ssh-ed25519`, `ecdsa-sha2-nistp256` or `ssh-rsa` line each. ops-01 reads them on the host
|
||||
(`/etc/ssh/ssh_host_*_key.pub`) and hands them to Jason out of band. The pin comes from the host side, never
|
||||
from a first connection (210 section 4). The checks are 210 D3 layer 2's: the address rules, refusing
|
||||
loopback and this machine's names, the port range, the user pattern, and the key types.
|
||||
|
||||
The Console form for this (Q188: "Jason enters ... in the WebUI") waits on #412 like Approve does. Until
|
||||
then the CLI is the only way in.
|
||||
|
||||
### E2. What the stack does
|
||||
|
||||
The bus child, on a human-only `hosts.enroll` verb:
|
||||
|
||||
1. writes the `hosts.<hostId>` registry entry through the grant writer (section 5, W1), with `hostKeys` set
|
||||
to the stdin lines
|
||||
2. generates an installer key pair with `ssh-keygen -t ed25519 -N '' -C mosaic-<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
|
||||
dirs, by the same tmp, fsync, rename steps as 210 `issue`
|
||||
4. prints the installer's `authorized_keys` line for ops-01:
|
||||
|
||||
```
|
||||
from="<stack host address>",restrict,command="/usr/local/sbin/mosaic-keys" ssh-ed25519 AAAA... mosaic-<business>-installer-<hostId>
|
||||
```
|
||||
|
||||
No private material goes out. The secrets dir layout is new. Sage or Mos may prefer
|
||||
`secrets/mosaic-stack/...`, which is the Console's (`webui/src/sso.mjs:35`). That is question Q-S2.
|
||||
|
||||
### E3. The host side: `mosaic-keys`
|
||||
|
||||
One small script in this repo, under `tools/host/mosaic-keys`, reviewed like any code. ops-01 installs it
|
||||
once per host as root, mode 0755, owned by root. It is the installer key's forced command, and it reads one
|
||||
request from `SSH_ORIGINAL_COMMAND`:
|
||||
|
||||
| Request | Effect |
|
||||
|---|---|
|
||||
| `list <user>` | prints each `authorized_keys` line of `<user>` whose comment starts `mosaic-` |
|
||||
| `add <user> <type> <base64> <tag>` | appends `from="<stack>",restrict <type> <base64> <tag>` unless the tag exists, through tmp and rename, keeping owner and mode |
|
||||
| `remove <user> <tag>` | drops the line with that exact tag, through tmp and rename |
|
||||
|
||||
Rules the script enforces on the host, so a stolen installer key can do no more than they allow:
|
||||
|
||||
- `<user>` must be in a host-side allowlist file, `/etc/mosaic-keys/users`, written by ops-01. The install
|
||||
user itself is never on it.
|
||||
- The script writes the `from=` value from `/etc/mosaic-keys/from`, also ops-01's. The stack can't choose it.
|
||||
- Each tag matches `^mosaic-[a-z0-9-]{1,96}$`, and each type and base64 matches the same patterns as E1.
|
||||
- The script never touches a line without the `mosaic-` prefix.
|
||||
- The script logs each request to syslog.
|
||||
|
||||
The install user `I` needs write access to `<user>`'s `authorized_keys` only. A `sudoers` line for exactly
|
||||
`/usr/local/sbin/mosaic-keys-write` (a root helper the script calls) is ops-01's choice. The design needs the
|
||||
effect and not a particular mechanism, and Duckworth's #423 condition (duckworth-0035) applies to whatever
|
||||
ops-01 runs.
|
||||
|
||||
This still doesn't make the installer key safe to lose. A key that can add a line for a NOPASSWD sudo user
|
||||
is root on that host. Section 9 says so.
|
||||
|
||||
### E4. Check
|
||||
|
||||
`mosaic hosts check <hostId>` connects with the installer key, the pinned `known_hosts` and 210's ssh
|
||||
options, and runs `list <user>`. A wrong pin exits 255 and refuses `host-key-mismatch`, as 210 does. Enrollment
|
||||
is done when the check passes. `status` shows each host's pin fingerprints, the installer fingerprint and the
|
||||
last check time.
|
||||
|
||||
`mosaic hosts remove <hostId>` is human only. It refuses while any grant names the host, then deletes the
|
||||
installer key and the registry entry. ops-01 removes the installer line on the host.
|
||||
|
||||
## 4. The request and the decision
|
||||
|
||||
### R1. Up the chain
|
||||
|
||||
An agent that needs host access sends the chief a message, as any request to the chief goes today
|
||||
(`#checkMessaging`, `broker.mjs:617-627`). There is no structured request verb in r1. F1 gives the reason:
|
||||
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
|
||||
`{"service": "host"}` in `credentials` (210 D3 layer 1, a reviewed commit). An agent whose definition lacks
|
||||
it can ask, but the chief's raise for it refuses `host-not-declared`. Which definitions declare `host` is
|
||||
the reviewed list of agents that may hold a host grant.
|
||||
|
||||
A structured `grant.ask` verb (the agent files the request, the chief raises it citing the request id, and
|
||||
the broker checks the pair) is the stronger form. It is question Q-S1.
|
||||
|
||||
### R2. The action
|
||||
|
||||
`host.grant` is a new action in `ACTIONS` and in neither `ROUTED` list, so it is a hard wait for every role
|
||||
(F3, `vocabulary.mjs:88`). PRD 1.7 has no requirement for it. Like `host.sudo` in 210 section 5, it needs a
|
||||
PRD line (section 8). It is never routed, because Q188 keeps grants with Jason.
|
||||
|
||||
I chose a new action over `credential.mint` (F10). `credential.mint` names no host or argv, and a test pins
|
||||
the hard-wait list, so either choice changes vocabulary. The narrower name keeps a future Gitea mint
|
||||
separate.
|
||||
|
||||
### R3. Who may raise it
|
||||
|
||||
The broker refuses a `host.grant` raise unless the raiser's definition is `chief-of-staff`
|
||||
(`b.roles[s.role].definition === STAFF_TO`, the check at `broker.mjs:593`). Refusal: `grant-chief-only`. A tester
|
||||
thread can't raise it, because it routes to the human (`broker.mjs:1683`).
|
||||
|
||||
### R4. What the raise binds
|
||||
|
||||
New raise argument `grant`, required when `action === 'host.grant'` and refused otherwise:
|
||||
|
||||
```
|
||||
grant: { "agent": "<instance>", "host": "<hostId>", "commands": [["systemctl", "status"], ...],
|
||||
"rotateBy": "YYYY-MM-DD" }
|
||||
```
|
||||
|
||||
Checks at raise, all inside the raise transaction:
|
||||
|
||||
- `agent` is a role instance of this business, and its definition declares `host` (R1)
|
||||
- `host` is enrolled (section 3)
|
||||
- `commands` passes 210 D6's argv rules: no wildcard, no shell, no empty prefix, and the existing
|
||||
refusals for `bash` and the like
|
||||
- `rotateBy` is a date inside 210's rotation window
|
||||
|
||||
The broker appends a rendered block to the question, as 210 D4 does for `host.sudo`: agent, host id,
|
||||
address, user, each argv prefix JSON-quoted, the rotate date, and whether this is a new key or a change to
|
||||
an existing grant's commands. The chief can't shape that block. The whole question keeps the 4096-character
|
||||
cap (`broker.mjs:126`).
|
||||
|
||||
The raise event body gets `hostGrant = {agent, host, commandsSha256, rotateBy, registrySha256}`.
|
||||
`registrySha256` hashes the host's registry entry at raise time. If ops-01 re-pins the host between raise and
|
||||
Approve, the executor refuses `decision-mismatch`, and the chief raises again.
|
||||
|
||||
### R5. Jason decides
|
||||
|
||||
`mosaic decide <id> yes|no` as today (F5). After #435, the Console's Approve calls the same
|
||||
`decision.resolve` through `human-cli.mjs` behind Q175 sign-in. The executor keys on the resolution, not on
|
||||
the client, so #435 needs no executor change.
|
||||
|
||||
## 5. The executor
|
||||
|
||||
### X1. When it runs
|
||||
|
||||
At resolve. Today a resolution runs nothing (F4), and the raiser comes back later to consume the decision.
|
||||
Here the raiser is the chief, and Q188 says no agent creates the grant, so the chief must not be the one
|
||||
whose call does it. The bus child starts the executor itself after the human's `decision.resolve` commits,
|
||||
when all of these hold: the decision's action is `host.grant`, the route is `human`, the choice equals
|
||||
`approvalChoice`, and `via === 'cli'`.
|
||||
|
||||
The resolve call returns at once with `{grant: "started"}`. It does not wait on ssh. Jason sees the outcome
|
||||
in `mosaic hosts status` and as a message in his inbox from `system` when the executor ends.
|
||||
|
||||
The executor consumes the decision through the same `action.allowed` event `#consumeAuthority` writes
|
||||
(`broker.mjs:808-831`), with `operation: 'host.grant'`, in a transaction before any remote step. A second
|
||||
start finds it spent and refuses `decision-consumed`. One executor runs at a time per (agent, host),
|
||||
through 210's busy map (210: `credentials.mjs:118`).
|
||||
|
||||
### X2. Steps
|
||||
|
||||
1. **Recheck** inside one transaction: the decision is resolved yes by the human via cli and unspent, the
|
||||
agent and its definition still declare `host`, the registry entry hashes to `registrySha256`, and no
|
||||
revoke of this (agent, host) is newer than the raise. Then write the consume event. Any failure is
|
||||
`grant.failed` with its code, and nothing remote has happened.
|
||||
2. **Key.** For a new grant, `ssh-keygen -t ed25519 -N '' -C <tag>` in a fresh 0700 dir under
|
||||
`<configDir>/secrets/run`, where tag is `mosaic-<business>-<agent>-<hostId>`. For a commands change
|
||||
with a live key, skip to step 5.
|
||||
3. **Install.** Connect as `I` with the installer key, pinned (210's options), and send
|
||||
`add <user> <type> <base64> <tag>`. If the tag exists on the host, `remove` it first, because an old line
|
||||
under the same tag belongs to a key the stack no longer holds.
|
||||
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>`
|
||||
and ends `grant.failed`.
|
||||
5. **Business file.** One write through the grant writer (W1): `roles.<agent>.hosts.<hostId>.commands` set to
|
||||
the decided list, and for a new key `roles.<agent>.credentials["host.<hostId>"] = {"store": true,
|
||||
"rotateBy": D}`.
|
||||
6. **Store.** 210 `issue(agent, "host.<hostId>", privateKey, rotateBy)` writes the key file and fills
|
||||
memory. The tmp dir from step 2 is removed in a `finally`.
|
||||
7. **Event and message.** `grant.applied {decision, agent, host, fingerprint, commandsSha256}` and a message
|
||||
to the human and the chief. The private key appears in no event, message or log (210 D8's
|
||||
`assertCleanKey` runs on every event body).
|
||||
|
||||
### X3. Failure and restart
|
||||
|
||||
The steps are ordered so a failure leaves nothing an agent can use:
|
||||
|
||||
| Fails at | State left | Cleanup |
|
||||
|---|---|---|
|
||||
| 1 | nothing | none |
|
||||
| 2 | tmp dir | `finally`, and the start sweep |
|
||||
| 3 | maybe a line with a key nobody holds | `remove <user> <tag>`, best effort |
|
||||
| 4 | a line, no grant | `remove`, best effort |
|
||||
| 5 | a line, no grant | `remove`, best effort |
|
||||
| 6 | a line, a grant, no key file: calls refuse `credential-unavailable` (199 D3a) | `remove`, then a writer pass that drops layers 3 and 4 |
|
||||
|
||||
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-` line whose tag has no live grant.
|
||||
|
||||
A bus child that dies mid-executor leaves a spent decision with no `grant.applied` or `grant.failed`. At start,
|
||||
the bus records `grant.failed {code: 'interrupted'}` for each such decision and runs no remote step. Jason
|
||||
gets a message. The chief raises again, and Jason approves again. I chose a new decision over an automatic
|
||||
retry because Jason approved one attempt at one moment, and a retry after a restart might be hours later.
|
||||
|
||||
### W1. The grant writer
|
||||
|
||||
A second writer on the same lock, revision, tmp, rename and receipt steps as `organizationWriter.write`
|
||||
(F6), with its own key set:
|
||||
|
||||
- `hosts.<hostId>`, written by `hosts.enroll` and `hosts.remove` only
|
||||
- `roles.<agent>.hosts.<hostId>` and `roles.<agent>.credentials["host.<hostId>"]`, written by the executor
|
||||
and by revoke only
|
||||
|
||||
The receipt's actor is `grant:<decisionId>`, `revoke:<by>` or `enroll:human`. No broker verb takes these
|
||||
keys from a request body. `organization.set`, `limits.set` and `instances.set` keep refusing `hosts`, which
|
||||
210 T3 already tests.
|
||||
|
||||
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.
|
||||
|
||||
## 6. Revoke
|
||||
|
||||
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
|
||||
Jason's terminal. Q-M1 asks Mos which.
|
||||
|
||||
Order, local first so the part that matters can't wait on the network:
|
||||
|
||||
1. 210 `revoke`: memory, abort the `host.run` in flight (SIGKILL to the ssh process group), unlink the key
|
||||
file. The call answers `credential-unavailable`. This is the "instant" part, and it doesn't touch the
|
||||
network.
|
||||
2. The grant writer drops `roles.<agent>.hosts.<hostId>`. The credential reference stays as a store
|
||||
reference with no file, which reads as revoked (199). A new grant needs a new decision.
|
||||
3. `remove <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.
|
||||
|
||||
Step 3 is defense in depth. After step 1 the stack holds no copy of the key. The line only matters if
|
||||
something copied the key file before the revoke, which section 9's same-user limit allows.
|
||||
|
||||
Remote processes that the killed ssh started may keep running (210 section 6). Revoke cuts the stack's
|
||||
connection, not what already runs on the host.
|
||||
|
||||
`hosts remove` (E4) refuses while grants exist. Revoking the installer itself is `hosts remove` and ops-01
|
||||
deleting the line.
|
||||
|
||||
## 7. Row 210's per-call decision becomes manual mode
|
||||
|
||||
Row 210 lands as reviewed (mos-1115). Its D4 stays the code path: a `host.run` whose class is `gated` needs a
|
||||
decision bound to host, argv hash and stdin hash, spent once.
|
||||
|
||||
What changes is when the class is `gated`:
|
||||
|
||||
- Under PRD 1.7, `host.sudo` is a hard wait, so every call is gated. That is today.
|
||||
- Under PRD 1.8 with REQ-CRED-4, `host.sudo` is routed, and a definition that lists it within-role runs
|
||||
granted commands with no decision (210 section 5).
|
||||
- Manual mode (Q188) puts the per-call decision back. I propose a business-file key, human-only and written
|
||||
through the grant writer, `"manual": ["host.sudo"]`, a list of routed actions that classify `gated` for
|
||||
every role while listed. `#classification` checks it first (`broker.mjs:597-609`). A list rather than a
|
||||
flag, so Jason can make host commands manual without making every routed action manual.
|
||||
|
||||
The manual mode key is PRD 1.8 territory. Honker drafts REQ-CRED-4 through Sage. I bring this proposal to that
|
||||
draft and don't build it in the grant rows. Q-S3 asks Sage where it belongs.
|
||||
|
||||
## 8. PRD lines (proposal for 1.8, beside REQ-CRED-4, through Sage)
|
||||
|
||||
> **REQ-CRED-5.** Only the chief raises a host grant. Jason approves it, and stack code then creates the key,
|
||||
> installs it on the host, and records the grant. No agent writes a grant or holds a host key's private half
|
||||
> outside the store. A grant names an agent, a host and exact argv prefixes. Revoke takes effect at once on
|
||||
> the stack's side, and the host line is removed when the host can be reached.
|
||||
|
||||
I don't edit the PRD. Sage takes this to Mos with Honker's REQ-CRED-4.
|
||||
|
||||
## 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
|
||||
`<configDir>/secrets`, readable by the stack's OS user like every other key (199 D6). `mosaic-keys` limits
|
||||
which users it can touch, not what those users can do.
|
||||
- **The seat network isn't confined** (`seat/src/sandbox.mjs:29-31`, 210 section 6). An agent granted a
|
||||
command that writes a file as root can plant its own key and ssh around the bus. `from=` doesn't help,
|
||||
because the sandbox shares the stack's address. A network-confined sandbox closes it (Sage's follow-up).
|
||||
- **The human proof is cooperative same-UID** (`bus/src/human.mjs:50-52`). Approve is as strong as
|
||||
`verifyHuman`, no stronger. This path adds nothing weaker, but grants raise the stakes of that check.
|
||||
- **Approve is not "runs without a click"** until PRD 1.8 routes `host.sudo` (section 0).
|
||||
- **Enrollment still needs ops-01 once per host.** I don't see a way around it without a trust anchor on the
|
||||
host, and I'd rather say so than claim the flow is hands-free.
|
||||
- **The pin trusts what Jason pastes.** Whoever writes the stdin of `hosts enroll` chooses the trusted key.
|
||||
|
||||
## 10. Tests and mutants (for the rows, not built here)
|
||||
|
||||
Tests use a scratch sshd on 127.0.0.1 with throwaway keys, as row 210 does, with `mosaic-keys` running as the
|
||||
scratch sshd's forced command against a scratch `authorized_keys`. No real host is contacted.
|
||||
|
||||
| Test | Claim |
|
||||
|---|---|
|
||||
| G1 | a `host.grant` raise from a role that isn't the chief refuses `grant-chief-only`, and nothing is stored |
|
||||
| G2 | a raise for an agent whose definition lacks `host` refuses `host-not-declared` |
|
||||
| G3 | a raise with a wildcard, shell or empty argv prefix refuses, the same cases as 210 D6 |
|
||||
| G4 | the rendered block is appended by the broker, the chief's question text can't forge it, and the 4096 cap holds |
|
||||
| G5 | `host.grant` classifies gated for every role and routes to `human`, and a tester thread can't raise it |
|
||||
| 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` |
|
||||
| G8 | each failure row of X3 leaves the stated state, and a failed `remove` leaves `hostLineLeft` |
|
||||
| G9 | a registry change between raise and Approve refuses `decision-mismatch` before any remote step |
|
||||
| G10 | a second start for the same decision refuses `decision-consumed` |
|
||||
| G11 | a bus restart mid-executor gives `grant.failed interrupted` and runs no remote step |
|
||||
| G12 | revoke: the call in flight dies, the key file is gone, the grant entry is gone, the host line is gone, in that order |
|
||||
| G13 | revoke with the host down still does steps 1 and 2 and reports `hostLineLeft` |
|
||||
| G14 | `organization.set`, `limits.set` and `instances.set` still refuse `hosts` and `manual` |
|
||||
| G15 | no event, message, log line or receipt holds private key material (210 D8 check on every body) |
|
||||
| G16 | `mosaic-keys`: users outside its list, lines without the `mosaic-` prefix, bad tags and bad key text all refuse, and `from=` comes from the host file |
|
||||
| G17 | `hosts enroll` refuses loopback and this machine's names and bad key lines, and prints no private material |
|
||||
| G18 | `hosts check` with a wrong pin exits 255, refuses `host-key-mismatch` and runs nothing |
|
||||
|
||||
Mutants, a first list. The row's BUILD.md numbers them and adds more:
|
||||
|
||||
- drop the chief check
|
||||
- drop the definition check
|
||||
- skip the commands hash compare
|
||||
- skip the registry hash compare
|
||||
- swap the order of install and the business write
|
||||
- skip `remove` on a verify failure
|
||||
- skip the start sweep
|
||||
- drop `from=` in `mosaic-keys`
|
||||
- let `mosaic-keys` touch an unprefixed line
|
||||
- run revoke's host step before the local steps
|
||||
- start the executor on an arbiter resolve
|
||||
- start it on `no`
|
||||
- write the private key into the event body
|
||||
|
||||
## 11. Not in this draft
|
||||
|
||||
- Gitea and GitHub grants. The same flow: chief raise, Jason approves, the executor mints a token through
|
||||
an admin credential Jason enters, writes the reference, and revoke deletes the token at the service.
|
||||
The admin scope is refused today (`vocabulary.mjs:105-106`), so that is a design of its own.
|
||||
- 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).
|
||||
|
||||
## 12. Questions
|
||||
|
||||
- **Q-S1 (Sage).** Is "message the chief, and the definition gates the raise" enough for "up the chain"
|
||||
in the first row? Or do you want the `grant.ask` verb (R1) now? It adds one verb and one table. I'd
|
||||
start without it.
|
||||
- **Q-S2 (Sage, Mos).** Where should installer keys live: `secrets/hosts/<business>/<hostId>/installer`
|
||||
(my proposal) or under `secrets/mosaic-stack/`?
|
||||
- **Q-S3 (Sage).** Does manual mode (section 7) go into Honker's PRD 1.8 draft beside REQ-CRED-4, and is
|
||||
REQ-CRED-5 (section 8) one line Sage carries with it?
|
||||
- **Q-S4 (Sage).** My suggested split, yours to size. Row a: enrollment, `mosaic-keys`, the grant writer,
|
||||
and `hosts status`. Row b: the `host.grant` action, the raise binding, the executor and revoke. Row b
|
||||
waits on row a and on 210's landing. Both need the PRD line before landing, as 210 did.
|
||||
- **Q-M1 (Mos).** How does Mos revoke: as the tester (`--tester`) or from Jason's terminal?
|
||||
- **Q-O1 (ops-01, through Mos).** Is a root-owned forced-command script with a host-side user list and
|
||||
`from=` file acceptable on the hosts? If ops-01 would rather run something else on the host, the
|
||||
stack-side design stays the same as long as the three requests in E3 behave as stated.
|
||||
Reference in new issue
Block a user