Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4151e6bc4 |
@@ -131,6 +131,32 @@ the plan says it too.
|
||||
| Genesis raises the forge as its module declares it | a genesis test that raises, assigns, and finds the module holding rather than raising a second |
|
||||
| Live | the next cutover on an adopted machine: `take` shows the comparison, refuses the downgrade if there is one, and the service keeps its configuration and its secret |
|
||||
|
||||
## Built, 2026-10-02
|
||||
|
||||
> **Progressive insight — 2026-10-02.** The decision stands; these are the facts of its building.
|
||||
|
||||
Built across mesh-host 63 and 64 and mesh-controller 201, 202 and the pull request that followed
|
||||
them. Rule 1: `take` previews every held thing's comparison and ends with a digest; `take --yes
|
||||
<digest>` acts on exactly that preview, and a changed preview or an account older than the flip
|
||||
allows is refused, as the flip's are. A published port's reach is said as the machine reported it,
|
||||
behind the found firewall whose rules are not read. Rule 2: an older image, a differing file and a
|
||||
minted, unaccepted secret for found data refuse, overridden by `--downgrade`, `--replace <path>` and
|
||||
`--mint <name>`; the secrets a module holds on a machine are read with where each came from. Rule 3:
|
||||
`secret accept --provider` reaches a required secret. Rule 4: the per-machine setting is `networks`,
|
||||
a container id to the found networks it keeps; judged for an adopted machine only, joined by the host
|
||||
after the container runs, part of the container's spec, named in the preview. Rule 5: the host's
|
||||
facts, former targets and strays. Rule 6: one judgement, run where a setting is stored and where a
|
||||
machine is composed; a module whose stored setting its definition can no longer compose is left out
|
||||
of the declaration, the envelope says so, the host keeps that module's things, and `plan` and `push`
|
||||
say it by name. A key that reaches nothing is refused where stored and said by `plan`, and never
|
||||
costs a module. Rule 7: genesis raises the forge under the module's container name, with its image
|
||||
digest and its data directory; the network is the one difference left, said by the take, because the
|
||||
bootstrap forge reaches the store on the machine's loopback.
|
||||
|
||||
**Not yet proven live.** Every machine of this mesh is converged, so the table's last row — a take
|
||||
on an adopted machine — waits for the next adoption. What is live is what the rows above it check.
|
||||
Issues 086, 098, 099, 100 and 101 stay located until that row is read.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md), [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md), [ADR 0103](0103-what-an-adopted-node-holds-and-what-its-guard-refuses.md), [ADR 0104](0104-a-provision-may-be-answered-by-an-adapter-to-the-predecessor.md), [ADR 0162](0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
layer: to-be
|
||||
status: in-progress
|
||||
code: [mesh-host]
|
||||
updated: 2026-10-01
|
||||
updated: 2026-10-02
|
||||
decisions:
|
||||
- 02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md
|
||||
- 02-DECISIONS/0141-the-host-delivers-its-own-successor.md
|
||||
@@ -163,6 +163,19 @@ a resource's former targets, removes a container or file it wrote under a name t
|
||||
longer names, never removes what was found, and reports what runs on the machine that it neither
|
||||
wrote nor holds. *How it is checked:* ADR 0163's table.
|
||||
|
||||
**What the host joins, keeps and raises for a take** — revision, 2026-10-02
|
||||
([ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rules 4, 6 and 7). A
|
||||
container may name networks it also joins once it runs — the found network a per-machine setting keeps
|
||||
for a taken container while a neighbour still resolves it there; joined after the run, part of the
|
||||
container's spec, refused when it cannot be joined. A declaration may name the modules the mesh left
|
||||
out of it because a stored setting cannot compose with the module's definition: the host keeps what it
|
||||
wrote and holds for a left-out module and says so, where absence used to read as removal. And genesis
|
||||
raises the bootstrap forge under the forge module's container name, with the module's image digest and
|
||||
its data directory, so the module holds it by the found rule; the network is the one difference a take
|
||||
has left to say. *How it is checked:* a host test joins a kept network and refuses one it cannot; a
|
||||
host test keeps a left-out module's record and hold and removes an absent module's; a bootstrap test
|
||||
holds the installer's constants to the module's manifest where the catalogue is checked out beside it.
|
||||
|
||||
**Found reaches every kind that can touch what the machine has**
|
||||
([ADR 0103](../../02-DECISIONS/0103-what-an-adopted-node-holds-and-what-its-guard-refuses.md)). For a module not yet taken, a directory present with no record
|
||||
keeps its mode and owner, a unit present with no record keeps its state and boot setting, a
|
||||
|
||||
@@ -8,7 +8,7 @@ code:
|
||||
- mesh-host packaging/nox-mesh-host-network.sh
|
||||
- mesh-controller internal/token
|
||||
- mesh-controller internal/inventory/nodes.go
|
||||
updated: 2026-10-01
|
||||
updated: 2026-10-02
|
||||
decisions:
|
||||
- 02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md
|
||||
- 02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md
|
||||
@@ -323,6 +323,21 @@ network are said. `take --yes <digest>` cuts over what was previewed, as the fli
|
||||
container may keep a found network by a per-machine setting while its neighbours are not yet taken.
|
||||
*How it is checked:* ADR 0163's table.
|
||||
|
||||
**A setting is judged where it is stored, and the take's words** — revision, 2026-10-02
|
||||
([ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rules 1, 2, 4 and 6).
|
||||
The preview ends with a digest of what it said; `take --yes <digest>` acts on that preview and nothing
|
||||
else, and a preview that has changed since, or an account of the machine older than the flip allows, is
|
||||
refused as the flip's is. A module the machine holds nothing for has nothing to compare, and `--yes`
|
||||
suffices. The overrides are `--downgrade`, `--replace <path>` and `--mint <name>`; the per-machine
|
||||
setting that keeps a found network is `networks`, a container id to the networks it keeps, accepted
|
||||
for an adopted machine only. Storing a setting composes it against the module's current definition and
|
||||
refuses, naming node, module, layer and key, what cannot compose or reaches nothing. A definition that
|
||||
later moves under a stored setting costs that module its place in the machine's declaration, said by
|
||||
name in `plan`, `push` and the declaration itself, and the machine is told everything else; a stray
|
||||
setting no longer refuses the machine where it is read. *How it is checked:* controller tests over the
|
||||
one judgement — refused where stored, a module left out where composed, the envelope naming it — and
|
||||
over a take's digest, staleness and secrets.
|
||||
|
||||
A candidate machine is not empty. It has a package manager, probably a container runtime,
|
||||
configuration somebody chose. [ADR 0005](../../02-DECISIONS/0005-the-node-host.md)
|
||||
says the host never touches what it did not create — adoption is the deliberate act of taking
|
||||
|
||||
@@ -40,3 +40,9 @@ it changes before it changes it, and for taking a module this one does not.
|
||||
|
||||
[ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rules 1 and 2: the preview names a narrowing. Building follows,
|
||||
host first, then the controller's `take`.
|
||||
|
||||
## Built, 2026-10-02
|
||||
|
||||
mesh-controller 201 and the pull request after it: the preview names it, and `take --yes <digest>`
|
||||
acts on the preview that was read. Stays located until a take is read on an adopted machine — every
|
||||
machine of this mesh is converged today, so the record's live row has not been run.
|
||||
|
||||
+10
@@ -53,3 +53,13 @@ network, or it is not a takeover.
|
||||
|
||||
[ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rule 7: genesis raises as the module declares. Building follows,
|
||||
host first, then the controller's `take`.
|
||||
|
||||
## Built in part, 2026-10-02
|
||||
|
||||
mesh-host 64: genesis raises the forge under the module's container name (`gitea`), pinned to the
|
||||
module's image digest, with the module's data directory mounted at `/data` — so the module finds it,
|
||||
holds it, and a take compares equal images and the same data. A test holds the installer's constants
|
||||
to the module's manifest where the catalogue is checked out beside it. The network is the difference
|
||||
left: the bootstrap forge runs on the machine's network to reach the store on its loopback, the module
|
||||
runs bridged and publishes its ports, and the take says so. Closing waits for group 9's genesis test —
|
||||
a mesh raised, the module assigned, and the module found holding rather than raising a second forge.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
status: located
|
||||
status: resolved
|
||||
opened: 2026-09-23
|
||||
located-in: [mesh-controller cmd/mesh-controller/adoption.go (take previews nothing), mesh-host internal/apply (the comparison and the record)]
|
||||
fixed-by:
|
||||
fixed-by: mesh-controller (the pull request after 201: JudgeSettings, LeftOut), mesh-host 64 (left_out kept)
|
||||
amended-design:
|
||||
---
|
||||
|
||||
@@ -63,3 +63,12 @@ knowing the code.
|
||||
|
||||
[ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rule 6: judged where stored; an impossible statement costs a module. Building follows,
|
||||
host first, then the controller's `take`.
|
||||
|
||||
## Resolved, 2026-10-02
|
||||
|
||||
One judgement, in the catalogue, run where a setting is stored and where a machine is composed. Stored,
|
||||
a setting that cannot compose with the module's current definition is refused naming the node, the
|
||||
module, the layer and the key; a key that reaches nothing is refused there too. Composed, a definition
|
||||
that moved under a stored setting leaves that module out of the machine's declaration — the envelope
|
||||
names it, the host keeps what it holds and wrote for it, `plan` and `push` say it — and the machine is
|
||||
told everything else. A stray setting no longer refuses the whole machine where it is read.
|
||||
|
||||
+10
-2
@@ -1,8 +1,8 @@
|
||||
---
|
||||
status: located
|
||||
status: resolved
|
||||
opened: 2026-09-23
|
||||
located-in: [mesh-controller cmd/mesh-controller/adoption.go (take previews nothing), mesh-host internal/apply (the comparison and the record)]
|
||||
fixed-by:
|
||||
fixed-by: mesh-host 63 (former targets removed, strays reported), mesh-controller 201/202 (strays shown)
|
||||
amended-design:
|
||||
---
|
||||
|
||||
@@ -80,3 +80,11 @@ found, and so would be kept for ever on purpose.
|
||||
|
||||
[ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rule 5: former targets are removed and strays reported. Building follows,
|
||||
host first, then the controller's `take`.
|
||||
|
||||
## Resolved, 2026-10-02
|
||||
|
||||
mesh-host 63: the host's record keeps a resource's former targets, removes a container or file it
|
||||
wrote under a name the declaration no longer names, never what was found, and reports strays — what
|
||||
runs on the machine that the mesh neither wrote nor holds. mesh-controller 201 and 202 show strays
|
||||
on `node show` for an adopted and a converged machine alike; the live mesh reported four on the
|
||||
control node the evening it rolled.
|
||||
|
||||
@@ -68,3 +68,9 @@ written.
|
||||
|
||||
[ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rules 1 and 2: the difference is shown and a differing file refuses. Building follows,
|
||||
host first, then the controller's `take`.
|
||||
|
||||
## Built, 2026-10-02
|
||||
|
||||
mesh-host 63 reports the difference between the kept original and the declared content; mesh-controller
|
||||
201 shows it in the preview and refuses a differing file unless `--replace <path>` names it, or the
|
||||
module declares the file partially. Stays located until a take is read on an adopted machine.
|
||||
|
||||
@@ -65,3 +65,9 @@ expected rate.
|
||||
|
||||
[ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rules 1 and 2: the images are compared by age and a downgrade refuses. Building follows,
|
||||
host first, then the controller's `take`.
|
||||
|
||||
## Built, 2026-10-02
|
||||
|
||||
mesh-host 63 reports the found image and both images' creation dates; mesh-controller 201 says
|
||||
DOWNGRADE and refuses unless `--downgrade` is said. Stays located until a take is read on an adopted
|
||||
machine.
|
||||
|
||||
@@ -70,3 +70,11 @@ the module can only be installed fresh.
|
||||
|
||||
[ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rules 2 and 3: a minted secret for found data refuses; secret accept reaches required secrets. Building follows,
|
||||
host first, then the controller's `take`.
|
||||
|
||||
## Built, 2026-10-02
|
||||
|
||||
`secret accept <node> <module> <name> --provider <node>` reaches a required secret (mesh-controller 201).
|
||||
The pull request after it reads every secret a module holds on a machine with its origin, and a take
|
||||
of a module whose data was found refuses a minted, unaccepted one — naming the accept that carries
|
||||
the existing value in, or `--mint <name>` to let the service take the new one. Stays located until
|
||||
a take is read on an adopted machine.
|
||||
|
||||
+7
@@ -66,3 +66,10 @@ exercise.
|
||||
|
||||
[ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rules 1 and 4: the neighbours are named; a found network may be kept by a setting. Building follows,
|
||||
host first, then the controller's `take`.
|
||||
|
||||
## Built, 2026-10-02
|
||||
|
||||
The preview names every neighbour on a found network (mesh-controller 201). The pull request after it
|
||||
adds the per-machine setting `networks` — a container id to the found networks it keeps — judged for an
|
||||
adopted machine only, and mesh-host 64 has the taken container join each once it runs. Stays located
|
||||
until a take is read on an adopted machine.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
---
|
||||
status: located
|
||||
status: resolved
|
||||
opened: 2026-09-26
|
||||
located-in: [mesh-host internal/apply]
|
||||
fixed-by: mesh-host 63 (every written field compared), mesh-controller 201 (build says the policy)
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 126 — a volume path is not in the spec comparison, and a roll-out raced a data move
|
||||
@@ -50,3 +52,9 @@ the install-page junk was discarded twice.
|
||||
|
||||
[ADR 0163](../../02-DECISIONS/0163-taking-a-module-over-is-a-comparison.md), rules 5 and 7: every field compared; build says the policy. Building follows,
|
||||
host first, then the controller's `take`.
|
||||
|
||||
## Resolved, 2026-10-02
|
||||
|
||||
mesh-host 63: every field the host writes is compared before a container is called current, volumes
|
||||
and paths included. mesh-controller 201: `build` and the take-in say when a module's policy rolls a
|
||||
result out at once; under ADR 0162 the plan says it too.
|
||||
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
---
|
||||
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
@@ -1,112 +0,0 @@
|
||||
---
|
||||
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