Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24aeb203f7 | ||
|
|
afbfd5f29d | ||
|
|
696957aa5e | ||
|
|
b13ef1be81 |
@@ -1,11 +1,8 @@
|
||||
---
|
||||
layer: to-be
|
||||
status: in-progress
|
||||
code:
|
||||
- mesh-controller internal/inventory
|
||||
- mesh-controller internal/catalogue
|
||||
- mesh-controller cmd/mesh-controller
|
||||
updated: 2026-10-01
|
||||
status: proposed
|
||||
code: []
|
||||
updated: 2026-09-27
|
||||
decisions:
|
||||
- 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
|
||||
- 02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md
|
||||
@@ -17,10 +14,10 @@ decisions:
|
||||
# 29 — A node has operator accounts, and the mesh owns what lives under a home
|
||||
|
||||
**The mesh models machines but not the people on them.** A node record holds its name, its
|
||||
address, its mode — and nothing about *who a person is* on it: one login name on the build node,
|
||||
another on the home-server, a third on both workstations. That username is not incidental. It
|
||||
decides who a file under `~` is owned by, who a user service runs as, and — the case that surfaced
|
||||
this — which account `ssh <node>` logs in as. The predecessor knew it (its per-node `user:`, and the modules that wrote a
|
||||
address, its mode — and nothing about *who a person is* on it: `jochens` on novox, `ace` on ace,
|
||||
`jochen` on shanks and g14. That username is not incidental. It decides who a file under `~` is
|
||||
owned by, who a user service runs as, and — the case that surfaced this — which account `ssh
|
||||
<node>` logs in as. The predecessor knew it (its per-node `user:`, and the modules that wrote a
|
||||
person's `~/.ssh/config`, `~/.zshrc`, `~/.config`); the mesh, taking those over, kept the machine
|
||||
facts and dropped the human one.
|
||||
|
||||
@@ -31,9 +28,8 @@ Several things are missing, and they are one idea.
|
||||
A node has one or more **operator accounts**: the human logins on it. At minimum a name; the
|
||||
mesh already knows the node and its address, so `<account>@<node>` is then a complete answer to
|
||||
"who am I, where." It is the mesh's to hold because everything below is derived from it, and
|
||||
because it is exactly the fact that was silently lost — `ssh home-server` logged in under the
|
||||
workstation's own name, because nothing in the mesh said the home-server's account is a different
|
||||
one.
|
||||
because it is exactly the fact that was silently lost — `ssh ace` failed to `ace` because nothing
|
||||
in the mesh said ace's account is `ace`.
|
||||
|
||||
## 2. A resource may live under a home, owned by its account
|
||||
|
||||
@@ -69,14 +65,14 @@ create `~/.ssh` at `0700`, chown it to the account, and own the files it places
|
||||
|
||||
**The boundary — and it is the reason this is safe:** `~/.ssh` is the one directory where a wrong
|
||||
declaration locks a person out of their own machine. So the mesh's *found-vs-owned* semantics
|
||||
([ADR 0118](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md),
|
||||
([ADR 0126](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md),
|
||||
adoption) apply *inside* the home directory. The mesh **owns** the directory and the files above; it
|
||||
**holds as found — never rewrites, never removes** — the operator's own contents: their **private
|
||||
keys** and their **personal drop-ins** (`config.d/personal`, the personal `Host` aliases a
|
||||
workstation carries, exactly as `hosts.local` is the home the mesh never rewrites for `/etc/hosts`).
|
||||
Reconcile removing an unassigned `config.d/mesh` is fine; the same logic aimed at `id_ed25519` or an
|
||||
operator's own `authorized_keys` entry is a lockout. This is the login-channel cousin of the rule
|
||||
[ADR 0117](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md) draws for the uplink and the sshd
|
||||
[ADR 0125](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md) draws for the uplink and the sshd
|
||||
module draws for the firewall: **the mesh must never be able to arrange the one failure that severs
|
||||
its own way back in.** The carve-out is not a convenience; it is that rule, in `~/.ssh`.
|
||||
|
||||
@@ -112,12 +108,12 @@ found-vs-owned boundary of §3 is exactly what guarantees nothing already there
|
||||
|
||||
None of this needs a node to discover the mesh, and none of it needs a control-plane module of its
|
||||
own. The ssh files are **roster facts**
|
||||
([ADR 0120](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md)): once the
|
||||
([ADR 0128](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md)): once the
|
||||
roster view carries a node's **host key** and its **account** beside its name and address, the
|
||||
`ssh-client` module ships a template for `known_hosts`, `config` and `authorized_keys`, and the
|
||||
controller renders each node's copy from the full roster and pushes it. The mesh owns the data; the
|
||||
module owns ssh's format; the control plane gains no ssh syntax. It is the same act as composing a
|
||||
peer list or `/etc/hosts` — which is why there is **no control-node-only "mesh-ssh" module**: the
|
||||
peer list or `/etc/hosts` — which is why there is **no novox-only "mesh-ssh" module**: the
|
||||
centralization is the controller's composition, not a module that runs somewhere. Only non-secret
|
||||
facts travel (names, addresses, accounts, host keys, the CA public key); the private key stays the
|
||||
operator's, placed as an operator-owned file, referenced by path.
|
||||
@@ -133,44 +129,6 @@ operator's, placed as an operator-owned file, referenced by path.
|
||||
They meet at the account and the CA, not at a bespoke module. The `sshd` server side already exists;
|
||||
the client/identity side and the CA are the open pieces.
|
||||
|
||||
## What has shipped, and what has not
|
||||
|
||||
*Recorded 2026-10-01 from the controller's main branch, not from intent.*
|
||||
|
||||
**Built (mesh-controller, merged 2026-09-27):**
|
||||
|
||||
- **§1, the account as a node fact.** A node record carries an operator account and, optionally,
|
||||
its home. Empty is a real state — a freshly enrolled or headless machine has no operator account
|
||||
known yet — and an empty home means *derive it* (the superuser's home for the superuser, the
|
||||
conventional per-user home otherwise), so the common case needs no entry. The controller's node
|
||||
command sets it. One account per node is what exists; "one or several" below is still open.
|
||||
- **§2, resources under a home.** The account and its home are offered as machine facts, and a
|
||||
resource's *path and owner* resolve placeholders exactly as its content does — so a module places
|
||||
a file under a person's home, owned by that person, naming neither. A roster file may say it lives
|
||||
under the home: it is rendered per node, placed under that node's account's home, chowned to the
|
||||
account, and a node with no account gets none.
|
||||
- **§5, the composed ssh config.** The roster rendering carries each node's account, so the
|
||||
`ssh-client` template can emit a `Host` block per node with the right login name. Composed
|
||||
end-to-end in the controller's tests.
|
||||
|
||||
**Written but not shipped:** the `ssh-client` catalogue module itself exists on a branch of the
|
||||
module repository; its pull request was closed with a hold until this design is deployed, and
|
||||
nothing has deployed it since. The predecessor's generator still writes every workstation's ssh
|
||||
client blocks today — which is where [issue 172](../../04-ISSUES/172-the-ssh-client-block-matches-one-spelling-of-a-machine/00-report.md)
|
||||
was found.
|
||||
|
||||
**Not built:** the SSH CA and certificates (§4), `known_hosts` and `authorized_keys` as roster files,
|
||||
the found-vs-owned boundary inside `~/.ssh` (§3 — the controller has no rule yet that refuses to
|
||||
rewrite a private key), adoption of existing keys, the ssh-agent as a user service, and user-scoped
|
||||
services in general. The host vocabulary still has no user-scope unit at all; a workstation's
|
||||
per-user daemons (a bar watchdog, a config reloader, an audio service masked per user) have no form
|
||||
the mesh can send.
|
||||
|
||||
**A gap this surfaced:** §1 shipped as code before it had a decision record. The account as a node
|
||||
fact, the home as a placement root, and what the mesh may and may not do under a home are each a
|
||||
decision this document names but no record states. They are the next records to write, before the
|
||||
family of §2 modules is built.
|
||||
|
||||
## Why now, and why not yet
|
||||
|
||||
**Why it matters:** when HAL retires, the generators that keep `~/.ssh`, shell config and the
|
||||
@@ -179,7 +137,7 @@ alias and its trust, and a fresh machine has no operator dotfiles at all — the
|
||||
service and leave the human unable to work on the box.
|
||||
|
||||
**Why not build it reflexively:** it is a real addition to the node model, the resource model, and
|
||||
the seat set, and must be gotten right. The mechanism half is now settled — ADR 0120 is what lets
|
||||
the seat set, and must be gotten right. The mechanism half is now settled — ADR 0128 is what lets
|
||||
the ssh files be templates with no control-plane format — so what remains to decide here is the
|
||||
model:
|
||||
|
||||
@@ -198,18 +156,15 @@ model:
|
||||
unnecessary, and forwarding an agent into a node exposes the operator's keys to that node's root —
|
||||
so prefer certificates and `ProxyJump` over forwarding.
|
||||
|
||||
**Now load-bearing.** The migration of every node to the mesh is complete; what remains of the
|
||||
predecessor is exactly the user environment this design covers — ssh config, dotfiles, the desktop
|
||||
stack and the per-user services of the two workstations. Those generators are the last thing
|
||||
keeping the predecessor running, so the model questions above are no longer deferred: the account
|
||||
record, the home as a placement root, user-scoped services and the one-off steps a hook used to run
|
||||
each need a decision before the modules that replace the generators can be written.
|
||||
**Not urgent, not blocking.** ssh and dotfiles work today because HAL's generators still run as the
|
||||
substrate. This becomes load-bearing in the node-by-node retirement phase, not before — which is the
|
||||
right time to build it, once the account and CA model are decided here.
|
||||
|
||||
## References
|
||||
|
||||
- The gap was found generating `~/.ssh/config` from the *HAL* registry (`hal/terminal`'s
|
||||
postConfigure hook), which the nox mesh has no equivalent for.
|
||||
- [ADR 0120](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md) — the roster
|
||||
- [ADR 0128](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md) — the roster
|
||||
fact mechanism that renders the ssh files, format owned by the module.
|
||||
- [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md) — the
|
||||
system-path placement this mirrors for home paths.
|
||||
@@ -218,6 +173,6 @@ each need a decision before the modules that replace the generators can be writt
|
||||
- [ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md) — the CA key is a secret the
|
||||
vault makes; [ADR 0114](../../02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md)
|
||||
— short-lived certs as rotation.
|
||||
- [ADR 0117](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md),
|
||||
[ADR 0118](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md) —
|
||||
- [ADR 0125](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md),
|
||||
[ADR 0126](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md) —
|
||||
the never-sever-the-channel rule and the found-vs-owned semantics, applied here to `~/.ssh`.
|
||||
|
||||
@@ -38,7 +38,7 @@ document is written and this one's status becomes `implemented`.
|
||||
| [`26-the-seats.md`](26-the-seats.md) | **Proposed.** What a mesh can have one of, who fills each, and a seat's holder answering for the provision it delivers — including the `git` seat a build's source can live on | [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)), [ADR 0111](../../02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md), [ADR 0109](../../02-DECISIONS/0109-a-package-registry-seat-is-one-per-ecosystem.md) |
|
||||
| [`27-a-module-requires-the-mesh-resolves.md`](27-a-module-requires-the-mesh-resolves.md) | **Proposed.** One concept for everything a module needs: a requirement with a contract, answered by one of four kinds of provider, resolved at assignment or refused. Retires settings, placeholders, facts and paths in definitions | [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md), [ADR 0114](../../02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md), [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)) |
|
||||
| [`28-building-the-bus.md`](28-building-the-bus.md) | **Proposed.** The five steps of the bus work in the order their dependencies allow, each ending at a bed — with the surface measured, so no step's size is a guess | [ADR 0116](../../02-DECISIONS/0116-the-bus-is-built-in-five-steps.md), [ADR 0106](../../02-DECISIONS/0106-the-bus-is-nats.md), [ADR 0074](../../02-DECISIONS/0074-the-wire-is-specified-not-the-types.md) |
|
||||
| [`29-a-node-has-operator-accounts.md`](29-a-node-has-operator-accounts.md) | **In progress.** A node has an operator account and a resource may live under its home — built in the controller; the ssh-client module, the SSH CA, the `~/.ssh` boundary and user-scoped services are not. The account fact still wants its decision record | [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0051](../../02-DECISIONS/0051-shared-data-is-the-operators.md) |
|
||||
| [`29-a-node-has-operator-accounts.md`](29-a-node-has-operator-accounts.md) | **Proposed.** The mesh models machines but not the humans on them: a node gains operator accounts, and a resource may live under a home owned by its account — what would own ~/.ssh, dotfiles and ~/.config when HAL retires | [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0051](../../02-DECISIONS/0051-shared-data-is-the-operators.md) |
|
||||
|
||||
| [`32-what-a-module-declares.md`](32-what-a-module-declares.md) | **Proposed.** What a module declares and what the bus derives from it: three namespaces, subjects from local names, queues never declared, the five relationships, and the build-publish-deploy lifecycle on one bus | [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md), [ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md), superseded by [ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md) (superseding [ADR 0125](../../02-DECISIONS/0125-the-bus-is-the-only-broker.md)), [ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) |
|
||||
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
---
|
||||
status: open
|
||||
opened: 2026-10-02
|
||||
located-in: [mesh-catalog modules/mesh-console, mesh-controller cmd/mesh-controller/plan.go (port assignment)]
|
||||
fixed-by:
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 192 — The mesh's tools reach a person only by a registration made by hand
|
||||
|
||||
## What was observed
|
||||
|
||||
A design session on a workstation had none of the mesh's tools. The console was running on that
|
||||
machine and answering on its loopback port. It was reached over the bus as the console's account, and
|
||||
listed every running module's tools and every seat's verbs
|
||||
([ADR 0152](../../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md)). What was missing
|
||||
was the registration that tells the person's coding agent where the console is. That registration had
|
||||
been made by hand, once, while migrating the machine, and scoped to the one project directory it was
|
||||
made in. Every session started anywhere else had no mesh tools. Nothing said so: the agent simply
|
||||
offered no mesh tools, and the session fell back to a pull-request link for a person to open by hand.
|
||||
|
||||
The predecessor did this job itself: it wrote its tool server into the agent's user configuration on
|
||||
every machine. Migrating removed that entry, as it should have, and no module took the job over.
|
||||
|
||||
## Why this is here
|
||||
|
||||
Three gaps, each of which would have stopped a module from doing it even if one existed.
|
||||
|
||||
**1. The console tells nobody where it is.** Its definition listens on a port and provides nothing.
|
||||
A module that wanted to point an agent at the console has no requirement it could name, so it
|
||||
would have to write the address into its own definition as a literal. That is exactly what
|
||||
[ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md) and
|
||||
[ADR 0155](../../02-DECISIONS/0155-a-definition-names-no-installation-and-how-that-is-checked.md)
|
||||
remove.
|
||||
|
||||
**2. The console's port is one its definition chose.** The definition names a port, and the mesh
|
||||
never assigned one: no port assignment exists for the console on any machine. The plan assigns a
|
||||
machine port only to a port a container publishes through a mapping, "without one the software binds
|
||||
what it binds". The console runs on the host network with no mapping, but it reads its listening
|
||||
address from `${port:…}`, so the mesh could move it and does not. That is a module choosing a
|
||||
machine port, which [ADR 0038](../../02-DECISIONS/0038-the-mesh-assigns-the-port.md) exists to
|
||||
prevent, through a gap in how the rule is applied rather than a decision against it. A module that
|
||||
reads its port from the mesh should be assigned one like any other.
|
||||
|
||||
**3. Nothing in the mesh owns a person's agent configuration.** No catalogue module writes the agent's
|
||||
settings, its tool-server registrations, or the rules and skills the predecessor delivered. On the
|
||||
four machines these are hand-kept, or left over from the predecessor, or missing.
|
||||
|
||||
## What a fix looks like (not decided)
|
||||
|
||||
- **The console provides its endpoint.** A provision, working name `mesh-tools`, served as the URL on
|
||||
the machine port the mesh gives it. The console listens only on loopback, so the provider must be on
|
||||
the consumer's own machine. Co-location already chooses it
|
||||
([ADR 0084](../../02-DECISIONS/0084-which-provider-serves-a-consumer.md)), and a machine with no
|
||||
console refuses the consumer, naming the provision.
|
||||
- **A module for the coding agent requires it** and writes the registration into the agent's
|
||||
system-wide managed settings. The agent reads tool servers from a `managedMcpServers` key there. That
|
||||
file is the machine's rather than a user's, so the module owns it whole and no home directory is
|
||||
named. People keep their own registrations beside it. The agent's separate *exclusive* managed
|
||||
file is the wrong one: it blocks every registration a person makes and hides the hosted connectors.
|
||||
The agent's per-user file is rewritten by the agent continuously and sits in a home directory,
|
||||
which would make its path an operator value. These facts come from the agent's documentation
|
||||
(managed MCP and managed settings pages), not yet verified on a machine.
|
||||
- **The same module owns the rest of the agent's configuration** the predecessor delivered: managed
|
||||
settings and the rules, skills and instructions every session reads. Each declared setting carries
|
||||
a default (ADR 0164,
|
||||
proposed on its own branch), so one configuration serves every machine and one machine may differ.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Is the agent's configuration one module or several?** Tool registration, managed settings, and
|
||||
the instruction files have different readers and change at different rates.
|
||||
- **Whose machine port is the console's?** Should a host-network container that reads its port from
|
||||
`${port:…}` be assigned one, or should a machine-only listener keep its declared number? The second
|
||||
needs a decision, because ADR 0038 does not allow it today.
|
||||
- **Credentials.** The console's authority is the machine's login (ADR 0152). A registration that
|
||||
reaches it carries no secret today. If the console ever listens beyond loopback, the registration
|
||||
needs one, from the vault.
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
---
|
||||
status: resolved
|
||||
opened: 2026-10-02
|
||||
located-in: [mesh-catalog modules/postgres/client.ts (readOnlyQuery)]
|
||||
fixed-by: [mesh-catalog PR 209 (postgres), mesh-catalog PR 210 (mssql)]
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 193 — The store seat's read-only query is read-only by convention, and its answer is unreadable
|
||||
|
||||
## What was observed
|
||||
|
||||
Asking the store seat's `query` verb for a count through the console returned this. Rows are each
|
||||
wrapped in an object under a key named `BEGIN`: the column name, then the value, then the word
|
||||
`ROLLBACK`. A query returning nothing gave the column name and `ROLLBACK` alone. The answer to
|
||||
`select count(*) as n from <table>` was:
|
||||
|
||||
> `rows: [ {BEGIN: "n"}, {BEGIN: "46"}, {BEGIN: "ROLLBACK"} ]`
|
||||
|
||||
A reader can work it out. A program cannot, and a query with two columns loses which value belongs to
|
||||
which.
|
||||
|
||||
## Why this is here
|
||||
|
||||
**The cause is the same line that makes the query read-only.** The holder's tool sends
|
||||
`BEGIN TRANSACTION READ ONLY; <the caller's statement>; ROLLBACK;` to the command-line client as one
|
||||
string. The client prints a command tag for each of the three statements, and the parser takes the
|
||||
first line, `BEGIN`, as the header.
|
||||
|
||||
**And it is not read-only.** [ADR 0159](../../02-DECISIONS/0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md)
|
||||
decided the store seat's `query` verb is "one read-only statement against one database". The only
|
||||
thing enforcing that is the wrapping transaction, and the caller's statement is pasted inside it as
|
||||
text. A statement that begins by ending the transaction (a commit, then anything) runs whatever
|
||||
follows it outside the read-only transaction, with the holder's own role, the administrative one that creates every
|
||||
consumer's role and database. A rule stated in a decision and enforced by string concatenation is enforced by nothing.
|
||||
|
||||
*This is read from the code, not tried against the live store, and it should not be tried there.*
|
||||
The lab bed is where it gets proven.
|
||||
|
||||
Every caller with `invokes` on the store seat's `query` can do this. The console has `invokes: ["*"]`,
|
||||
so that includes anyone logged in on a machine running the console.
|
||||
|
||||
## What a fix looks like
|
||||
|
||||
- **One statement, refused otherwise.** Send the caller's statement alone, through the client's
|
||||
single-statement path (the extended protocol takes one statement per call and refuses more). The
|
||||
read-only property then comes from the session, not from text around the statement.
|
||||
- **Read-only by role, not by transaction.** Run the verb as a role that can only read, granted
|
||||
`pg_read_all_data`, not as the administrative role. A statement that escapes every wrapper still cannot write.
|
||||
- **Rows as rows.** Parse the client's output with the column names it returns, or use a driver
|
||||
instead of the command-line client, so a row is an object keyed by its columns.
|
||||
- **The check 0159 lacks:** a test that sends a commit followed by a write and asserts the write is
|
||||
refused and nothing changed. Another asserts a two-column row comes back keyed by both columns.
|
||||
|
||||
## Proven, 2026-10-02
|
||||
|
||||
On a throwaway server — the same engine image, no network, reached over a socket — the module's code
|
||||
from the catalogue's main branch ran `COMMIT; COPY (select 1) TO PROGRAM '<a command>'` and **the
|
||||
command ran on the database host** as the server's own user. `COMMIT; DROP TABLE t` executed the drop
|
||||
outside the read-only transaction; the wrapper's own trailing rollback happened to undo it, which a
|
||||
caller ending their statement with a commit of their own would get past (not tried). Nothing was tried
|
||||
against the live store.
|
||||
|
||||
The fix (mesh-catalog PR 209) runs the caller's statement as a login granted `pg_read_all_data` and
|
||||
nothing else, read-only by its role and its session, with a password the mesh mints as one of the
|
||||
module's own secrets; without that password the call is refused rather than run as the admin. On the
|
||||
same throwaway server every escape above, and `SET ROLE`, `RESET SESSION AUTHORIZATION`, turning
|
||||
read-only off, creating a table, altering the role and reading a server file, is refused; a plain
|
||||
select comes back keyed by its columns. One attempt — turning the transaction's read-only off, then
|
||||
deleting — got past the first layer and was stopped by the second, which is why both exist.
|
||||
|
||||
**Not answered by the statement-count fix proposed above.** The command-line client sends one string
|
||||
in one message, so several statements still arrive together. They are harmless as the reader, and
|
||||
refusing them is left to whoever moves the module to a driver.
|
||||
|
||||
## The same hole, elsewhere — and two worse ones
|
||||
|
||||
The `mssql` module wrapped a caller's statement the same way (`BEGIN TRANSACTION; … ROLLBACK;` as its
|
||||
administrator) for its `mssql_query` tool. Its command-line client added two holes of its own. Both
|
||||
were proven on a throwaway server, running the client the way the module ran it:
|
||||
|
||||
- **It substitutes `$(NAME)` from its environment into the caller's text**, and the administrator's
|
||||
password is in that environment. Selecting it as a string returned the password.
|
||||
- **It reads a line beginning `:!!` as a command that starts a program**, in the container that holds
|
||||
the administrator's password and the module's bus credentials. Its switch for refusing such commands
|
||||
makes the shipped version ignore the statement entirely, so the switch cannot be the guard.
|
||||
|
||||
None of it was reachable on the live mesh, for a reason that is a defect of its own: the runtime image
|
||||
never installed the client, so every mssql tool failed (`spawn sqlcmd ENOENT`). The fix (mesh-catalog
|
||||
PR 210) installs the client at a pinned digest and runs the caller's statement as a login that can
|
||||
connect and read and do nothing else. Substitution is off. The statement must be one line, placed after
|
||||
the module's own text, so no line of it can begin a command; a line break is refused before the client
|
||||
starts. On the throwaway server, writes, `xp_cmdshell`, impersonating the administrator, and joining
|
||||
the administrators' role were all refused, and the variable came back as the literal text.
|
||||
|
||||
**The general lesson**, worth more than either module: *a command-line client is an interpreter with
|
||||
its own syntax, and a caller's text handed to it is a program in that syntax as well as in SQL.* A
|
||||
module that passes a caller's text to a client has two languages to defend, and a transaction drawn
|
||||
around the text defends neither.
|
||||
|
||||
## Resolved, 2026-10-02
|
||||
|
||||
Both pull requests merged, built and pushed to the two machines that run each module. Checked live, on
|
||||
every copy, by asking each one who it is:
|
||||
|
||||
- the store seat's `query`, and postgres's own tool on each machine, answer as the reader login —
|
||||
not a superuser, in a read-only transaction — with rows keyed by their columns;
|
||||
- mssql's tool, on each machine, answers as its reader login, outside the administrators' role, and
|
||||
returns `$(SQLCMDPASSWORD)` as the literal text it is. Its tools work for the first time.
|
||||
|
||||
The escapes themselves were tried only on the throwaway servers above; on the live mesh the check is
|
||||
the identity a statement runs as, which is what makes every escape a statement that the login cannot do.
|
||||
Reference in New Issue
Block a user