lattice-mcp 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/verify.yml +33 -0
- package/AGENTS.md +74 -6
- package/README.md +40 -7
- package/index.js +177 -14
- package/package.json +3 -2
- package/verify.mjs +140 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: Verify
|
|
2
|
+
|
|
3
|
+
# lattice-mcp is published to npm by hand, so there is no build-and-deploy
|
|
4
|
+
# pipeline to hang this off. This workflow exists purely as a correctness gate on
|
|
5
|
+
# push and pull request.
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
branches: [master]
|
|
9
|
+
pull_request:
|
|
10
|
+
workflow_dispatch:
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
verify:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- name: Checkout code
|
|
18
|
+
uses: actions/checkout@v4.2.2
|
|
19
|
+
|
|
20
|
+
- name: Set up Node
|
|
21
|
+
uses: actions/setup-node@v4.1.0
|
|
22
|
+
with:
|
|
23
|
+
node-version: 20
|
|
24
|
+
cache: npm
|
|
25
|
+
|
|
26
|
+
- name: Install dependencies
|
|
27
|
+
run: npm ci
|
|
28
|
+
|
|
29
|
+
# Parses index.js, rejects duplicate tool names (server.tool silently
|
|
30
|
+
# lets the last registration win), and fails if README.md/AGENTS.md have
|
|
31
|
+
# drifted from the registered tool count.
|
|
32
|
+
- name: Verify
|
|
33
|
+
run: npm test
|
package/AGENTS.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> `lattice-mcp` is the **Model Context Protocol server for Lattice**, the container
|
|
4
4
|
> orchestration platform that runs every `appleby.cloud` service. It exposes the
|
|
5
|
-
> `lattice-api` admin surface to Claude Code as **
|
|
5
|
+
> `lattice-api` admin surface to Claude Code as **133 typed tools** — workers, stacks,
|
|
6
6
|
> containers, deployments, databases, registries, networks, volumes and instance config.
|
|
7
7
|
> This file orients any agent/worker before touching code in this repo.
|
|
8
8
|
>
|
|
@@ -42,7 +42,7 @@ Those live in [`lattice-api`](https://github.com/aidenappl/lattice-api) and
|
|
|
42
42
|
|
|
43
43
|
| Path | Role |
|
|
44
44
|
|------|------|
|
|
45
|
-
| `index.js` | Everything: `--setup` flow, config read, `api()` HTTP helper, `text()`/`body()` helpers, all
|
|
45
|
+
| `index.js` | Everything: `--setup` flow, config read, `api()` HTTP helper, `text()`/`body()` helpers, all 133 `server.tool(...)` registrations, transport connect. |
|
|
46
46
|
| `package.json` | npm metadata. `bin.lattice-mcp` → `index.js`, so `npx lattice-mcp` works. |
|
|
47
47
|
| `README.md` | User-facing setup + full tool table. |
|
|
48
48
|
| `AGENTS.md` | This file. |
|
|
@@ -113,6 +113,40 @@ and supply the real token.
|
|
|
113
113
|
`HandleDatabaseAction` derives its action from the **last URL path segment**, not from a body
|
|
114
114
|
field.
|
|
115
115
|
|
|
116
|
+
### Sensitive value masking
|
|
117
|
+
|
|
118
|
+
`api()` passes every decoded JSON response through **`sanitise()`** before returning it. This is
|
|
119
|
+
central, not per-tool, so a newly added tool is safe by default rather than by remembering.
|
|
120
|
+
|
|
121
|
+
`mask()` keeps a value's **first two characters** and appends a **fixed-width tail** —
|
|
122
|
+
`"supersecret"` → `"su**********"`. The prefix is what makes the mask useful rather than merely
|
|
123
|
+
safe: you can still tell a `frt_` token from an `obk_` one, spot that two containers share a
|
|
124
|
+
key, or confirm a rotation actually changed a value. The tail is fixed width so the mask does
|
|
125
|
+
not disclose the real length. Values under three characters are masked whole.
|
|
126
|
+
|
|
127
|
+
Four rules, in the order `sanitise()` applies them:
|
|
128
|
+
|
|
129
|
+
| Field | Handling |
|
|
130
|
+
|-------|----------|
|
|
131
|
+
| `env_vars` | Parsed as JSON, then values whose **key** matches `isSecretName()` are masked. The blob is not masked wholesale — variable names are the useful half. An unparseable blob *is* masked wholesale rather than passed through. |
|
|
132
|
+
| `compose_yaml` | Assignment lines (`- NAME=value` and `NAME: value`) whose name matches `isSecretName()` are masked, without tracking YAML block structure. |
|
|
133
|
+
| `value` when the sibling `is_secret` is `true` | Masked. Global env vars are only secret when flagged, and masking the rest would hide image tags, ports and hostnames. |
|
|
134
|
+
| anything in `SECRET_FIELDS` | Masked at any nesting depth, in objects and arrays alike. |
|
|
135
|
+
|
|
136
|
+
`isSecretName()` matches password/secret/token/key/credential/dsn shapes but **excludes names
|
|
137
|
+
ending in `_url`, `_uri`, `_endpoint`, `_host`, `_port`, `_issuer`** — `TOKEN_URL` and
|
|
138
|
+
`AUTH_URL` are addresses, not credentials, and masking them makes an SSO misconfiguration much
|
|
139
|
+
harder to diagnose.
|
|
140
|
+
|
|
141
|
+
**Why this repo needs it more than the others:** this MCP authenticates as a Lattice **admin**,
|
|
142
|
+
and `lattice-api` only masks global env vars server-side *for non-admin callers*
|
|
143
|
+
(`HandleGlobalEnvVars.router.go`). Before masking, `lattice_list_env_vars` returned every
|
|
144
|
+
`is_secret` value in plaintext despite its own description claiming otherwise.
|
|
145
|
+
|
|
146
|
+
`LATTICE_ALLOW_SECRET_VALUES=1` disables masking entirely — for the cases where you genuinely
|
|
147
|
+
need a working credential (`lattice_reveal_database_credentials`, the token-creation tools).
|
|
148
|
+
It is off by default and should stay that way.
|
|
149
|
+
|
|
116
150
|
## Domain & architecture
|
|
117
151
|
|
|
118
152
|
**Auth.** A single long-lived bearer token (`LATTICE_API_TOKEN`) from
|
|
@@ -126,7 +160,7 @@ exits immediately if either is missing. In practice these come from the `env` bl
|
|
|
126
160
|
|
|
127
161
|
**Tool groups**, in file order:
|
|
128
162
|
|
|
129
|
-
The counts below sum to **
|
|
163
|
+
The counts below sum to **133**, matching the header and `grep -c 'server.tool(' index.js`. The
|
|
130
164
|
first rows are the original, pre-`1.1.0` tools (registered top-of-file with no banner comment);
|
|
131
165
|
every bolded row corresponds to a `// ───` banner group and matches its exact in-file name.
|
|
132
166
|
|
|
@@ -139,7 +173,8 @@ every bolded row corresponds to a `// ───` banner group and matches its ex
|
|
|
139
173
|
| Deployments | 4 | list/get/logs, rollback. (`lattice_approve_deployment` is *not* here — it lives under **Stacks — lifecycle, compose & deploy tokens**.) |
|
|
140
174
|
| Instance self-update | 2 | `lattice_update_api`, `lattice_update_web` — tell the API/web container to pull its latest image and redeploy itself |
|
|
141
175
|
| Audit & API tokens | 4 | `lattice_get_audit_log`; API token list/create/delete |
|
|
142
|
-
| **Database instances** | **
|
|
176
|
+
| **Database instances** | **19** | CRUD, `lattice_database_action` (start/stop/restart/remove enum), `lattice_get_database_connection`, `lattice_reveal_database_credentials`, `lattice_get_database_credentials` (deprecated), `lattice_get_database_events`, `lattice_get_database_logs`, `lattice_get_database_lifecycle_logs`, `lattice_open_database_console`, snapshot list/create/restore/delete |
|
|
177
|
+
| **Worker port allocation** | **1** | `lattice_get_worker_port_availability` — claimed host ports on a worker plus a free suggestion |
|
|
143
178
|
| **Backup destinations** | **6** | list/get/create/update/delete + `lattice_test_backup_destination` |
|
|
144
179
|
| **Registries** | **8** | list/create/update/delete, `lattice_test_registry`, `lattice_test_registry_inline`, `lattice_list_registry_repositories`, `lattice_list_registry_tags` |
|
|
145
180
|
| **Discovery & diagnostics** | **7** | `lattice_search`, `lattice_get_anomalies`, `lattice_get_fleet_metrics`, `lattice_get_versions`, `lattice_refresh_versions`, `lattice_get_container_metrics`, `lattice_get_self` |
|
|
@@ -168,6 +203,30 @@ params the handlers already accept but the tools were dropping: `lattice_get_aud
|
|
|
168
203
|
`user_id` / `action` / `resource_type` / `offset` (answer "who deleted stack X" server-side instead
|
|
169
204
|
of scanning 50 rows), and `lattice_get_container_logs` gains `offset` / `worker_id`. Tool count 125 → 126.
|
|
170
205
|
|
|
206
|
+
**1.2.0** covers the managed-database overhaul in `lattice-api`. Tool count 126 → 133.
|
|
207
|
+
|
|
208
|
+
New: `lattice_get_database_connection` (host/port/database/username, no secrets),
|
|
209
|
+
`lattice_reveal_database_credentials` (`POST .../reveal` — audited, root only when `include_root`
|
|
210
|
+
is set), `lattice_get_database_events` (**the first thing to reach for when a database is in an
|
|
211
|
+
unexpected state** — the lifecycle history explains how it got there), `lattice_get_database_logs`,
|
|
212
|
+
`lattice_get_database_lifecycle_logs`, `lattice_open_database_console`, and
|
|
213
|
+
`lattice_get_worker_port_availability`.
|
|
214
|
+
|
|
215
|
+
It also fixes two long-standing request-shape bugs verified against the handlers, both of the exact
|
|
216
|
+
kind this repo's rules warn about:
|
|
217
|
+
|
|
218
|
+
- `memory_limit` was documented as **bytes**. The API takes **megabytes** and multiplies before
|
|
219
|
+
sending to the worker, so any agent that followed the description passed a value roughly a
|
|
220
|
+
million times too small — and Docker rejects any limit under 6MB, so the create failed.
|
|
221
|
+
- `status` was a free string whose description advertised `creating`, which is not a value the
|
|
222
|
+
platform has ever used. It is now an enum matching `structs.DatabaseStatus`
|
|
223
|
+
(`pending`/`provisioning`/`running`/`stopped`/`restarting`/`degraded`/`deleting`/`error`), as is
|
|
224
|
+
`health_status`. The API now rejects anything else with a 400.
|
|
225
|
+
|
|
226
|
+
`lattice_create_database_instance`'s `port` is now documented as optional-and-preferably-omitted:
|
|
227
|
+
the API allocates a free port from 20000-29999 and returns 409 naming the conflict if you pin one
|
|
228
|
+
that is taken.
|
|
229
|
+
|
|
171
230
|
**Consolidations.** Where `lattice-api` exposes several paths served by one handler, this repo
|
|
172
231
|
exposes one tool with an enum rather than N tools. `lattice_database_action` covers
|
|
173
232
|
`/start`, `/stop`, `/restart` and `/remove`. Worker actions are the historical exception — they
|
|
@@ -204,8 +263,11 @@ predate this convention and remain separate tools.
|
|
|
204
263
|
|
|
205
264
|
- **Never hardcode a token, URL or hostname.** Everything comes from env.
|
|
206
265
|
- **Never log request or response bodies.** Responses routinely contain env vars, registry
|
|
207
|
-
credentials and database passwords
|
|
208
|
-
|
|
266
|
+
credentials and database passwords — do not add convenience logging anywhere in `api()`.
|
|
267
|
+
- **Never weaken `sanitise()`.** It must stay recursive, applied centrally in `api()`, and on by
|
|
268
|
+
default. Masking is the only thing standing between an admin token's responses and a
|
|
269
|
+
permanent transcript. If a tool needs real values, the answer is
|
|
270
|
+
`LATTICE_ALLOW_SECRET_VALUES=1` in that server's env, not an exemption in the code.
|
|
209
271
|
- **Never add a tool without reading its handler in `lattice-api`.** Inferring a request shape
|
|
210
272
|
from a struct has produced real, shipped bugs across this family of servers.
|
|
211
273
|
- **Do not break tool names.** They are a public contract: renaming one silently breaks any
|
|
@@ -237,6 +299,12 @@ For any tool you added or changed, make **one real call against the live API** a
|
|
|
237
299
|
response shape. Schema-only verification is not enough: it catches typos, not wrong units,
|
|
238
300
|
wrong enum values, or parameters the handler ignores.
|
|
239
301
|
|
|
302
|
+
**If you touched `api()`, `sanitise()`, `mask()` or the field lists**, re-verify masking: run a
|
|
303
|
+
nested fixture (objects inside arrays inside objects, an `env_vars` blob, a `compose_yaml`
|
|
304
|
+
string) through `sanitise()` and assert no live value survives at any depth. `npm test` checks
|
|
305
|
+
statically that `api()` still calls `sanitise()`, but it cannot check that the field lists are
|
|
306
|
+
still right.
|
|
307
|
+
|
|
240
308
|
**Never report work complete on the strength of `tools/list` alone.**
|
|
241
309
|
|
|
242
310
|
## Keeping this file updated
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Model Context Protocol server for [Lattice](https://github.com/aidenappl/lattice
|
|
|
8
8
|
|
|
9
9
|
## Overview
|
|
10
10
|
|
|
11
|
-
`lattice-mcp` is a single-file Node ESM program (`index.js`) that speaks MCP over stdio and translates tool calls into HTTP requests against the `lattice-api` admin surface. It exposes **
|
|
11
|
+
`lattice-mcp` is a single-file Node ESM program (`index.js`) that speaks MCP over stdio and translates tool calls into HTTP requests against the `lattice-api` admin surface. It exposes **133 typed tools** and holds no business logic, caching or state of its own — every behaviour (pagination, validation, side effects) comes from `lattice-api`.
|
|
12
12
|
|
|
13
13
|
Once configured, ask Claude Code things like:
|
|
14
14
|
|
|
@@ -74,6 +74,28 @@ Restart Claude Code after setup so the new server and tools are picked up.
|
|
|
74
74
|
|----------|----------|-------------|
|
|
75
75
|
| `LATTICE_API_URL` | Yes | Lattice API base URL |
|
|
76
76
|
| `LATTICE_API_TOKEN` | Yes | Bearer token for authentication (sent on every request) |
|
|
77
|
+
| `LATTICE_ALLOW_SECRET_VALUES` | No | Set to `1` to disable secret masking in responses |
|
|
78
|
+
|
|
79
|
+
## Secret values are masked
|
|
80
|
+
|
|
81
|
+
Every response is passed through a masking step before it reaches the model. Anything that looks
|
|
82
|
+
like a credential keeps its **first two characters** and loses the rest to a fixed-width tail —
|
|
83
|
+
`supersecret` becomes `su**********`.
|
|
84
|
+
|
|
85
|
+
That is enough to tell two credentials apart, or to confirm a rotation actually changed
|
|
86
|
+
something, and not enough to use. The tail is a fixed width so the mask does not reveal the real
|
|
87
|
+
length.
|
|
88
|
+
|
|
89
|
+
This covers container and stack `env_vars`, `compose_yaml` environment blocks, global env vars
|
|
90
|
+
flagged `is_secret`, database passwords, and freshly minted deploy/worker/API tokens. Variable
|
|
91
|
+
*names* are left readable — they are the useful half — as are addresses like `TOKEN_URL` and
|
|
92
|
+
`AUTH_URL`.
|
|
93
|
+
|
|
94
|
+
This server authenticates as a Lattice **admin**, and the API only masks global env vars
|
|
95
|
+
server-side for *non-admin* callers. Without this step, `lattice_list_env_vars` returns every
|
|
96
|
+
secret value in plaintext.
|
|
97
|
+
|
|
98
|
+
Set `LATTICE_ALLOW_SECRET_VALUES=1` to turn masking off if you genuinely need a working value.
|
|
77
99
|
|
|
78
100
|
## Development
|
|
79
101
|
|
|
@@ -83,12 +105,12 @@ Restart Claude Code after setup so the new server and tools are picked up.
|
|
|
83
105
|
| `npm install` | Install dependencies (not vendored) |
|
|
84
106
|
| `node --check index.js` | Syntax gate — the only static check that exists |
|
|
85
107
|
| `LATTICE_API_URL=… LATTICE_API_TOKEN=… node index.js` | Run the server on stdio |
|
|
86
|
-
| `grep -c 'server.tool(' index.js` | Confirm the tool count (should be
|
|
108
|
+
| `grep -c 'server.tool(' index.js` | Confirm the tool count (should be 133) |
|
|
87
109
|
| `npm publish` | Publish to npm — **this is deployment** (requires 2FA passkey from an interactive terminal) |
|
|
88
110
|
|
|
89
111
|
## Tools
|
|
90
112
|
|
|
91
|
-
All
|
|
113
|
+
All 133 tools, grouped as they appear in `index.js`. ⚠️ marks destructive tools; their descriptions state the blast radius.
|
|
92
114
|
|
|
93
115
|
### Overview & health
|
|
94
116
|
| Tool | Description |
|
|
@@ -162,15 +184,26 @@ All 126 tools, grouped as they appear in `index.js`. ⚠️ marks destructive to
|
|
|
162
184
|
|------|-------------|
|
|
163
185
|
| `lattice_list_database_instances` | List managed databases (filter by worker, engine, status) |
|
|
164
186
|
| `lattice_get_database_instance` | Full instance config |
|
|
165
|
-
| `lattice_create_database_instance` | Provision mysql/mariadb/postgres on a worker |
|
|
187
|
+
| `lattice_create_database_instance` | Provision mysql/mariadb/postgres on a worker (omit `port` to auto-allocate) |
|
|
166
188
|
| `lattice_update_database_instance` | Update config, limits, snapshot schedule |
|
|
167
189
|
| `lattice_delete_database_instance` | Delete an instance ⚠️ |
|
|
168
190
|
| `lattice_database_action` | start / stop / restart / remove ⚠️ |
|
|
169
|
-
| `
|
|
191
|
+
| `lattice_get_database_connection` | Host, port, database and username — no secrets |
|
|
192
|
+
| `lattice_reveal_database_credentials` | Reveal live credentials (audited; root only on request) |
|
|
193
|
+
| `lattice_get_database_credentials` | **Deprecated** — root credentials via GET; use the reveal tool |
|
|
194
|
+
| `lattice_get_database_events` | Lifecycle history — start here when a database looks wrong |
|
|
195
|
+
| `lattice_get_database_logs` | Container stdout/stderr |
|
|
196
|
+
| `lattice_get_database_lifecycle_logs` | Worker lifecycle messages, incl. why a create failed |
|
|
197
|
+
| `lattice_open_database_console` | Authorise an interactive SQL console session |
|
|
170
198
|
| `lattice_list_database_snapshots` | Snapshots for an instance |
|
|
171
199
|
| `lattice_create_database_snapshot` | Take a snapshot now |
|
|
172
200
|
| `lattice_restore_database_snapshot` | Restore from a snapshot ⚠️ |
|
|
173
|
-
| `lattice_delete_database_snapshot` | Delete a snapshot ⚠️ |
|
|
201
|
+
| `lattice_delete_database_snapshot` | Delete a snapshot and its remote file ⚠️ |
|
|
202
|
+
|
|
203
|
+
### Worker port allocation
|
|
204
|
+
| Tool | Description |
|
|
205
|
+
|------|-------------|
|
|
206
|
+
| `lattice_get_worker_port_availability` | Claimed host ports on a worker, plus a free suggestion |
|
|
174
207
|
|
|
175
208
|
### Backup destinations
|
|
176
209
|
| Tool | Description |
|
|
@@ -286,7 +319,7 @@ Everything lives in one file:
|
|
|
286
319
|
|
|
287
320
|
| Path | Role |
|
|
288
321
|
|------|------|
|
|
289
|
-
| `index.js` | The whole server: `--setup` flow, config read, `api()` HTTP helper, `text()`/`body()` helpers, all
|
|
322
|
+
| `index.js` | The whole server: `--setup` flow, config read, `api()` HTTP helper, `text()`/`body()` helpers, all 133 `server.tool(...)` registrations, transport connect. |
|
|
290
323
|
| `package.json` | npm metadata; `bin.lattice-mcp` → `index.js`. |
|
|
291
324
|
| `AGENTS.md` | Contributor/agent guide — conventions, handler contracts, verification. |
|
|
292
325
|
| `README.md` | This file. |
|
package/index.js
CHANGED
|
@@ -57,6 +57,113 @@ if (!API_URL || !API_TOKEN) {
|
|
|
57
57
|
process.exit(1);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// --- Sensitive value masking ---
|
|
61
|
+
//
|
|
62
|
+
// Lattice's admin API answers to an admin token, so it returns live
|
|
63
|
+
// credentials in full: container and stack env vars, global env var values
|
|
64
|
+
// (masking there is server-side and non-admin-only), database passwords,
|
|
65
|
+
// freshly minted deploy/worker/API tokens. None of that is needed to answer
|
|
66
|
+
// the questions these tools exist to answer, and anything that reaches a
|
|
67
|
+
// model's context is in a transcript forever.
|
|
68
|
+
//
|
|
69
|
+
// Sensitive values are masked to their first two characters plus a
|
|
70
|
+
// fixed-width tail: "supersecret" -> "su**********". The prefix keeps a value
|
|
71
|
+
// identifiable and comparable — you can still tell a frt_ token from an obk_
|
|
72
|
+
// one, or spot that two containers share the same key — while the fixed tail
|
|
73
|
+
// avoids disclosing the length.
|
|
74
|
+
//
|
|
75
|
+
// Set LATTICE_ALLOW_SECRET_VALUES=1 to pass values through unmasked.
|
|
76
|
+
|
|
77
|
+
const ALLOW_SECRETS = process.env.LATTICE_ALLOW_SECRET_VALUES === "1";
|
|
78
|
+
|
|
79
|
+
function mask(value) {
|
|
80
|
+
if (typeof value !== "string" || value === "") return value;
|
|
81
|
+
// Under three characters there is no prefix worth keeping — a two-char
|
|
82
|
+
// secret would otherwise round-trip as itself.
|
|
83
|
+
if (value.length < 3) return "**********";
|
|
84
|
+
return value.slice(0, 2) + "**********";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Response fields that are a credential wherever they appear.
|
|
88
|
+
const SECRET_FIELDS = new Set([
|
|
89
|
+
"password", "root_password", "client_secret", "secret", "secret_key",
|
|
90
|
+
"secret_access_key", "access_key", "access_key_id", "token", "api_token",
|
|
91
|
+
"admin_token", "deploy_token", "worker_token", "plaintext", "private_key",
|
|
92
|
+
"encryption_key", "signing_key", "connection_string", "dsn",
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
// Env-var and compose keys are free-form, so they are matched by shape rather
|
|
96
|
+
// than by name. The `_url`/`_uri`/`_endpoint` exclusion keeps TOKEN_URL and
|
|
97
|
+
// AUTH_URL readable — they are addresses, not credentials, and masking them
|
|
98
|
+
// makes an SSO misconfiguration much harder to diagnose.
|
|
99
|
+
const SECRET_NAME = /(pass(word|wd)?|secret|token|api[-_]?key|access[-_]?key|private[-_]?key|signing[-_]?key|encryption[-_]?key|credential|dsn|salt)/i;
|
|
100
|
+
const ADDRESS_NAME = /_(url|uri|endpoint|host|port|issuer)$/i;
|
|
101
|
+
|
|
102
|
+
function isSecretName(name) {
|
|
103
|
+
return SECRET_NAME.test(name) && !ADDRESS_NAME.test(name);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Container and stack env vars arrive as a JSON object encoded in a string.
|
|
107
|
+
// Masking the whole blob would hide the variable names too, which are the
|
|
108
|
+
// useful half — so it is parsed, filtered by key, and re-encoded.
|
|
109
|
+
function maskEnvBlob(raw) {
|
|
110
|
+
if (typeof raw !== "string" || raw === "") return raw;
|
|
111
|
+
let parsed;
|
|
112
|
+
try {
|
|
113
|
+
parsed = JSON.parse(raw);
|
|
114
|
+
} catch {
|
|
115
|
+
// Not the JSON object shape we expect — mask it wholesale rather than
|
|
116
|
+
// pass an unknown blob through.
|
|
117
|
+
return mask(raw);
|
|
118
|
+
}
|
|
119
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return raw;
|
|
120
|
+
const out = {};
|
|
121
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
122
|
+
out[k] = isSecretName(k) && typeof v === "string" ? mask(v) : v;
|
|
123
|
+
}
|
|
124
|
+
return JSON.stringify(out);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Compose YAML carries the same secrets in `environment:` blocks, in either
|
|
128
|
+
// `- NAME=value` or `NAME: value` form. Matching the assignment line directly
|
|
129
|
+
// avoids having to track YAML block structure.
|
|
130
|
+
const COMPOSE_ENV_LINE = /^(\s*-?\s*)([A-Za-z_][A-Za-z0-9_]*)(\s*[:=]\s*)(.+)$/;
|
|
131
|
+
|
|
132
|
+
function maskComposeYAML(raw) {
|
|
133
|
+
if (typeof raw !== "string" || raw === "") return raw;
|
|
134
|
+
return raw
|
|
135
|
+
.split("\n")
|
|
136
|
+
.map((line) => {
|
|
137
|
+
const m = line.match(COMPOSE_ENV_LINE);
|
|
138
|
+
if (!m || !isSecretName(m[2])) return line;
|
|
139
|
+
return m[1] + m[2] + m[3] + mask(m[4].trim());
|
|
140
|
+
})
|
|
141
|
+
.join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Walks a decoded response and masks every sensitive value in place.
|
|
145
|
+
function sanitise(node) {
|
|
146
|
+
if (ALLOW_SECRETS || node === null || typeof node !== "object") return node;
|
|
147
|
+
if (Array.isArray(node)) return node.map(sanitise);
|
|
148
|
+
const out = {};
|
|
149
|
+
for (const [k, v] of Object.entries(node)) {
|
|
150
|
+
if (k === "env_vars") {
|
|
151
|
+
out[k] = maskEnvBlob(v);
|
|
152
|
+
} else if (k === "compose_yaml") {
|
|
153
|
+
out[k] = maskComposeYAML(v);
|
|
154
|
+
} else if (k === "value" && node.is_secret === true) {
|
|
155
|
+
// Global env vars: the value is only a secret when flagged as one,
|
|
156
|
+
// and masking the rest would hide image tags, ports and hostnames.
|
|
157
|
+
out[k] = mask(v);
|
|
158
|
+
} else if (SECRET_FIELDS.has(k) && typeof v === "string") {
|
|
159
|
+
out[k] = mask(v);
|
|
160
|
+
} else {
|
|
161
|
+
out[k] = sanitise(v);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
60
167
|
// --- HTTP helper ---
|
|
61
168
|
|
|
62
169
|
async function api(method, path, params, body) {
|
|
@@ -90,7 +197,7 @@ async function api(method, path, params, body) {
|
|
|
90
197
|
// Never log bodies anywhere; responses can carry secrets.
|
|
91
198
|
const raw = await res.text();
|
|
92
199
|
try {
|
|
93
|
-
return JSON.parse(raw);
|
|
200
|
+
return sanitise(JSON.parse(raw));
|
|
94
201
|
} catch {
|
|
95
202
|
return {
|
|
96
203
|
success: false,
|
|
@@ -114,7 +221,7 @@ function body(obj) {
|
|
|
114
221
|
|
|
115
222
|
const server = new McpServer({
|
|
116
223
|
name: "lattice",
|
|
117
|
-
version: "1.
|
|
224
|
+
version: "1.2.0",
|
|
118
225
|
});
|
|
119
226
|
|
|
120
227
|
// Overview
|
|
@@ -438,7 +545,7 @@ server.tool("lattice_delete_api_token", "Delete an API token", {
|
|
|
438
545
|
server.tool("lattice_list_database_instances", "List managed database instances with engine, version, status, worker and health. Filter by worker, engine or status", {
|
|
439
546
|
worker_id: z.number().optional().describe("Filter by worker ID"),
|
|
440
547
|
engine: z.enum(["mysql", "mariadb", "postgres"]).optional().describe("Filter by engine"),
|
|
441
|
-
status: z.
|
|
548
|
+
status: z.enum(["pending", "provisioning", "running", "stopped", "restarting", "degraded", "deleting", "error"]).optional().describe("Filter by lifecycle status"),
|
|
442
549
|
limit: z.number().optional().describe("Max instances to return"),
|
|
443
550
|
offset: z.number().optional().describe("Pagination offset"),
|
|
444
551
|
}, async (args) => {
|
|
@@ -453,18 +560,18 @@ server.tool("lattice_get_database_instance", "Get one database instance: engine,
|
|
|
453
560
|
return { content: text(res) };
|
|
454
561
|
});
|
|
455
562
|
|
|
456
|
-
server.tool("lattice_create_database_instance", "Provision a database instance on a worker. Creates a real container
|
|
563
|
+
server.tool("lattice_create_database_instance", "Provision a database instance on a worker. Creates a real container. Omit `port` to have a free one allocated automatically — that is the recommended path; a port already in use returns 409 naming the conflict", {
|
|
457
564
|
name: z.string().describe("Instance name (must be unique)"),
|
|
458
565
|
engine: z.enum(["mysql", "mariadb", "postgres"]).describe("Database engine"),
|
|
459
566
|
worker_id: z.number().describe("Worker to provision on"),
|
|
460
567
|
engine_version: z.string().optional().describe("Engine version tag; the API picks a default when omitted"),
|
|
461
|
-
port: z.number().optional().describe("Host port to expose"),
|
|
568
|
+
port: z.number().optional().describe("Host port to expose. Omit to auto-allocate a free port from the managed range (20000-29999)"),
|
|
462
569
|
root_password: z.string().optional().describe("Root/superuser password"),
|
|
463
570
|
database_name: z.string().optional().describe("Initial database to create"),
|
|
464
571
|
username: z.string().optional().describe("Application user to create"),
|
|
465
572
|
password: z.string().optional().describe("Application user's password"),
|
|
466
573
|
cpu_limit: z.number().optional().describe("CPU limit in cores"),
|
|
467
|
-
memory_limit: z.number().optional().describe("Memory limit in bytes"),
|
|
574
|
+
memory_limit: z.number().optional().describe("Memory limit in MEGABYTES (the API converts to bytes; values under 6MB are rejected by Docker)"),
|
|
468
575
|
snapshot_schedule: z.string().optional().describe("Cron expression for automatic snapshots"),
|
|
469
576
|
retention_count: z.number().optional().describe("How many automatic snapshots to keep"),
|
|
470
577
|
backup_destination_id: z.number().optional().describe("Backup destination for snapshots"),
|
|
@@ -476,13 +583,13 @@ server.tool("lattice_create_database_instance", "Provision a database instance o
|
|
|
476
583
|
server.tool("lattice_update_database_instance", "Update a database instance's configuration. Only the fields you pass are changed", {
|
|
477
584
|
id: z.number().describe("Database instance ID"),
|
|
478
585
|
name: z.string().optional(),
|
|
479
|
-
status: z.
|
|
480
|
-
port: z.number().optional(),
|
|
586
|
+
status: z.enum(["pending", "provisioning", "running", "stopped", "restarting", "degraded", "deleting", "error"]).optional().describe("Lifecycle status — normally managed by the reconciler; set manually only to correct drift"),
|
|
587
|
+
port: z.number().optional().describe("New host port; rejected with 409 if already claimed on the worker"),
|
|
481
588
|
root_password: z.string().optional().describe("New root password"),
|
|
482
589
|
password: z.string().optional().describe("New application user password"),
|
|
483
590
|
cpu_limit: z.number().optional(),
|
|
484
|
-
memory_limit: z.number().optional(),
|
|
485
|
-
health_status: z.
|
|
591
|
+
memory_limit: z.number().optional().describe("Memory limit in MEGABYTES"),
|
|
592
|
+
health_status: z.enum(["none", "starting", "healthy", "unhealthy"]).optional(),
|
|
486
593
|
snapshot_schedule: z.string().optional().describe("Cron expression for automatic snapshots"),
|
|
487
594
|
retention_count: z.number().optional(),
|
|
488
595
|
backup_destination_id: z.number().optional(),
|
|
@@ -507,13 +614,69 @@ server.tool("lattice_database_action", "Start, stop, restart or remove a databas
|
|
|
507
614
|
return { content: text(res) };
|
|
508
615
|
});
|
|
509
616
|
|
|
510
|
-
server.tool("
|
|
617
|
+
server.tool("lattice_get_database_connection", "Get a database instance's host, port, database and username. Contains no secrets — prefer this over the credential tools when you only need to know where a database lives", {
|
|
618
|
+
id: z.number().describe("Database instance ID"),
|
|
619
|
+
}, async ({ id }) => {
|
|
620
|
+
const res = await api("GET", `/admin/database-instances/${id}/connection`);
|
|
621
|
+
return { content: text(res) };
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
server.tool("lattice_reveal_database_credentials", "Reveal a database instance's live credentials. Every call is audited and recorded against the instance. Returns the application user by default; set include_root only when root access is genuinely required. Passwords come back masked to their first two characters — enough to confirm which credential is deployed, not enough to use. Set LATTICE_ALLOW_SECRET_VALUES=1 in the MCP server env to get the real values, or read them from the Lattice UI", {
|
|
625
|
+
id: z.number().describe("Database instance ID"),
|
|
626
|
+
include_root: z.boolean().optional().describe("Also return the root/superuser password (default false)"),
|
|
627
|
+
}, async ({ id, include_root }) => {
|
|
628
|
+
const res = await api("POST", `/admin/database-instances/${id}/reveal`, null, body({ include_root }));
|
|
629
|
+
return { content: text(res) };
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
server.tool("lattice_get_database_credentials", "DEPRECATED — returns root credentials from a plain GET. Use lattice_reveal_database_credentials instead, which is audited and scoped", {
|
|
511
633
|
id: z.number().describe("Database instance ID"),
|
|
512
634
|
}, async ({ id }) => {
|
|
513
635
|
const res = await api("GET", `/admin/database-instances/${id}/credentials`);
|
|
514
636
|
return { content: text(res) };
|
|
515
637
|
});
|
|
516
638
|
|
|
639
|
+
server.tool("lattice_get_database_events", "Get a database instance's lifecycle history — every status transition, failure, reconciliation, console open and credential reveal. START HERE when a database is in an unexpected state: this is what explains how it got there", {
|
|
640
|
+
id: z.number().describe("Database instance ID"),
|
|
641
|
+
kind: z.enum(["requested", "accepted", "transition", "health", "failed", "reconciled", "console_open", "reveal"]).optional().describe("Filter by event kind"),
|
|
642
|
+
limit: z.number().optional().describe("Max events to return"),
|
|
643
|
+
}, async ({ id, ...params }) => {
|
|
644
|
+
const res = await api("GET", `/admin/database-instances/${id}/events`, params);
|
|
645
|
+
return { content: text(res) };
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
server.tool("lattice_get_database_logs", "Get a database container's stdout/stderr. Use together with lattice_get_database_events when diagnosing a failed or degraded instance — the events say what happened, the logs say why", {
|
|
649
|
+
id: z.number().describe("Database instance ID"),
|
|
650
|
+
stream: z.enum(["stdout", "stderr"]).optional().describe("Filter by stream"),
|
|
651
|
+
limit: z.number().optional().describe("Max log lines to return"),
|
|
652
|
+
}, async ({ id, ...params }) => {
|
|
653
|
+
const res = await api("GET", `/admin/database-instances/${id}/logs`, params);
|
|
654
|
+
return { content: text(res) };
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
server.tool("lattice_get_database_lifecycle_logs", "Get worker-emitted lifecycle messages for a database container, including provisioning progress and the reason a create failed", {
|
|
658
|
+
id: z.number().describe("Database instance ID"),
|
|
659
|
+
limit: z.number().optional().describe("Max entries to return"),
|
|
660
|
+
}, async ({ id, ...params }) => {
|
|
661
|
+
const res = await api("GET", `/admin/database-instances/${id}/lifecycle`, params);
|
|
662
|
+
return { content: text(res) };
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
server.tool("lattice_open_database_console", "Authorise an interactive console session against a running database and return the worker, container and SQL client command to run. The session itself runs over the admin WebSocket, so this returns the authorisation, not a live shell", {
|
|
666
|
+
id: z.number().describe("Database instance ID"),
|
|
667
|
+
}, async ({ id }) => {
|
|
668
|
+
const res = await api("POST", `/admin/database-instances/${id}/console`);
|
|
669
|
+
return { content: text(res) };
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
server.tool("lattice_get_worker_port_availability", "List host ports already claimed on a worker (by databases and by stack containers) and get a free suggestion. Check this before pinning a database to a specific port", {
|
|
673
|
+
id: z.number().describe("Worker ID"),
|
|
674
|
+
port: z.number().optional().describe("Check one specific port instead of listing all claims"),
|
|
675
|
+
}, async ({ id, ...params }) => {
|
|
676
|
+
const res = await api("GET", `/admin/workers/${id}/port-availability`, params);
|
|
677
|
+
return { content: text(res) };
|
|
678
|
+
});
|
|
679
|
+
|
|
517
680
|
server.tool("lattice_list_database_snapshots", "List snapshots for a database instance, with size and creation time. Check this before deleting or restoring an instance", {
|
|
518
681
|
id: z.number().describe("Database instance ID"),
|
|
519
682
|
}, async ({ id }) => {
|
|
@@ -801,7 +964,7 @@ server.tool("lattice_list_deploy_tokens", "List a stack's deploy tokens — the
|
|
|
801
964
|
return { content: text(res) };
|
|
802
965
|
});
|
|
803
966
|
|
|
804
|
-
server.tool("lattice_create_deploy_token", "Create a deploy token for a stack. The plaintext is returned once
|
|
967
|
+
server.tool("lattice_create_deploy_token", "Create a deploy token for a stack. The plaintext is returned once and this server masks it to its first two characters, so the usable value never enters a transcript — read it from the Lattice UI, or set LATTICE_ALLOW_SECRET_VALUES=1. Used as https://<lattice>/api/deploy/<token>?container=<name>", {
|
|
805
968
|
id: z.number().describe("Stack ID"),
|
|
806
969
|
name: z.string().describe("Token name, e.g. 'github-actions'"),
|
|
807
970
|
}, async ({ id, name }) => {
|
|
@@ -922,7 +1085,7 @@ server.tool("lattice_list_worker_tokens", "List a worker's registration tokens.
|
|
|
922
1085
|
return { content: text(res) };
|
|
923
1086
|
});
|
|
924
1087
|
|
|
925
|
-
server.tool("lattice_create_worker_token", "Create a registration token for a worker, used by the runner to connect. Plaintext is returned once", {
|
|
1088
|
+
server.tool("lattice_create_worker_token", "Create a registration token for a worker, used by the runner to connect. Plaintext is returned once and this server masks it to its first two characters — read the usable value from the Lattice UI, or set LATTICE_ALLOW_SECRET_VALUES=1", {
|
|
926
1089
|
id: z.number().describe("Worker ID"),
|
|
927
1090
|
name: z.string().describe("Token name"),
|
|
928
1091
|
}, async ({ id, name }) => {
|
|
@@ -1009,7 +1172,7 @@ server.tool("lattice_force_remove_container", "Force-remove a container on a wor
|
|
|
1009
1172
|
// Global env vars, templates, webhooks
|
|
1010
1173
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1011
1174
|
|
|
1012
|
-
server.tool("lattice_list_env_vars", "List global environment variables available for interpolation into stack and container configs as ${NAME}. Values marked is_secret are masked", {}, async () => {
|
|
1175
|
+
server.tool("lattice_list_env_vars", "List global environment variables available for interpolation into stack and container configs as ${NAME}. Values marked is_secret are masked to their first two characters by this server — the API itself only masks them for non-admin callers, and this MCP authenticates as an admin", {}, async () => {
|
|
1013
1176
|
const res = await api("GET", "/admin/env-vars");
|
|
1014
1177
|
return { content: text(res) };
|
|
1015
1178
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lattice-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "MCP server for Lattice container orchestration platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"lattice-mcp": "index.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"start": "node index.js"
|
|
11
|
+
"start": "node index.js",
|
|
12
|
+
"test": "node verify.mjs"
|
|
12
13
|
},
|
|
13
14
|
"keywords": [
|
|
14
15
|
"mcp",
|
package/verify.mjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Static verification for lattice-mcp.
|
|
4
|
+
*
|
|
5
|
+
* index.js cannot simply be imported to check it: it ends in a top-level await
|
|
6
|
+
* that connects the stdio transport, so importing would hang waiting for a
|
|
7
|
+
* client. Everything here is therefore checked by parsing the source.
|
|
8
|
+
*
|
|
9
|
+
* What this catches:
|
|
10
|
+
*
|
|
11
|
+
* - Duplicate tool names. `server.tool()` silently accepts a duplicate — the
|
|
12
|
+
* last registration wins and the earlier tool disappears with no error. That
|
|
13
|
+
* is invisible until an agent calls the vanished tool.
|
|
14
|
+
* - Tool counts in README.md and AGENTS.md drifting from the code. This repo
|
|
15
|
+
* already shipped a release where the MCP lagged the API by two months; the
|
|
16
|
+
* documented count is the cheapest tripwire for that.
|
|
17
|
+
* - Tools not following the lattice_ prefix, which the client relies on.
|
|
18
|
+
* - sanitise() being unwired from api(). Masking is applied in exactly one
|
|
19
|
+
* place; removing that call leaves every tool working and every response
|
|
20
|
+
* leaking, which no other check would notice.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
|
|
26
|
+
const failures = [];
|
|
27
|
+
const fail = (msg) => failures.push(msg);
|
|
28
|
+
|
|
29
|
+
const source = readFileSync("index.js", "utf8");
|
|
30
|
+
|
|
31
|
+
// ── Syntax ───────────────────────────────────────────────────────────────────
|
|
32
|
+
try {
|
|
33
|
+
execFileSync(process.execPath, ["--check", "index.js"], { stdio: "pipe" });
|
|
34
|
+
} catch (err) {
|
|
35
|
+
fail(`index.js failed to parse:\n${err.stderr?.toString() ?? err.message}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Tool registrations ───────────────────────────────────────────────────────
|
|
39
|
+
const names = [...source.matchAll(/server\.tool\(\s*"([^"]+)"/g)].map((m) => m[1]);
|
|
40
|
+
|
|
41
|
+
if (names.length === 0) {
|
|
42
|
+
fail("no server.tool() registrations found — did the call shape change?");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
const duplicates = new Set();
|
|
47
|
+
for (const name of names) {
|
|
48
|
+
if (seen.has(name)) duplicates.add(name);
|
|
49
|
+
seen.add(name);
|
|
50
|
+
}
|
|
51
|
+
if (duplicates.size > 0) {
|
|
52
|
+
fail(
|
|
53
|
+
`duplicate tool names (the later registration silently replaces the earlier): ${[...duplicates].join(", ")}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const misnamed = names.filter((n) => !n.startsWith("lattice_"));
|
|
58
|
+
if (misnamed.length > 0) {
|
|
59
|
+
fail(`tools not prefixed with lattice_: ${misnamed.join(", ")}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Documented counts must match the code ────────────────────────────────────
|
|
63
|
+
const toolCount = names.length;
|
|
64
|
+
|
|
65
|
+
for (const file of ["README.md", "AGENTS.md"]) {
|
|
66
|
+
const doc = readFileSync(file, "utf8");
|
|
67
|
+
const match = doc.match(/\*\*(\d+) typed tools\*\*/);
|
|
68
|
+
if (!match) {
|
|
69
|
+
fail(`${file}: could not find a "**N typed tools**" figure to check against`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const documented = Number(match[1]);
|
|
73
|
+
if (documented !== toolCount) {
|
|
74
|
+
fail(
|
|
75
|
+
`${file} documents ${documented} tools but index.js registers ${toolCount} — ` +
|
|
76
|
+
`update the docs in the same change (see "Keeping this file updated")`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// README lists every tool in a table; make sure the listing is complete too.
|
|
82
|
+
const readme = readFileSync("README.md", "utf8");
|
|
83
|
+
const undocumented = names.filter((n) => !readme.includes(`\`${n}\``));
|
|
84
|
+
if (undocumented.length > 0) {
|
|
85
|
+
fail(`tools missing from the README tool tables: ${undocumented.join(", ")}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Version consistency ──────────────────────────────────────────────────────
|
|
89
|
+
// The version lives in two places: package.json and the McpServer declaration.
|
|
90
|
+
// They drift silently — nothing reads both — and the failure surfaces only at
|
|
91
|
+
// `npm publish`, as "cannot publish over the previously published versions",
|
|
92
|
+
// after the release has already been tagged and pushed.
|
|
93
|
+
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
|
94
|
+
const serverVersion = source.match(/new McpServer\(\{[^}]*version:\s*"([^"]+)"/s)?.[1];
|
|
95
|
+
|
|
96
|
+
if (!serverVersion) {
|
|
97
|
+
fail("could not find the McpServer version declaration in index.js");
|
|
98
|
+
} else if (serverVersion !== pkg.version) {
|
|
99
|
+
fail(
|
|
100
|
+
`version mismatch: package.json is ${pkg.version} but index.js declares ${serverVersion} — ` +
|
|
101
|
+
`bump both`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// AGENTS.md documents each release; a bump with no matching entry means the
|
|
106
|
+
// release notes are already behind.
|
|
107
|
+
const agents = readFileSync("AGENTS.md", "utf8");
|
|
108
|
+
if (pkg.version && !agents.includes(`**${pkg.version}**`)) {
|
|
109
|
+
fail(`AGENTS.md has no "**${pkg.version}**" release entry — document the release in the same change`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Secret masking ───────────────────────────────────────────────────────────
|
|
113
|
+
// sanitise() is the only thing keeping env vars, database passwords and freshly
|
|
114
|
+
// minted tokens out of a transcript, and it works by being applied centrally in
|
|
115
|
+
// api(). Unwiring that one call is a silent, total regression — every tool keeps
|
|
116
|
+
// working and every response starts leaking. This is the tripwire for it.
|
|
117
|
+
if (!/return sanitise\(JSON\.parse\(raw\)\)/.test(source)) {
|
|
118
|
+
fail("api() no longer passes its parsed response through sanitise() — every tool now leaks secrets");
|
|
119
|
+
}
|
|
120
|
+
if (!/function sanitise\(/.test(source) || !/function mask\(/.test(source)) {
|
|
121
|
+
fail("sanitise()/mask() are missing from index.js");
|
|
122
|
+
}
|
|
123
|
+
// The masking must default to on; only an explicit opt-in env var disables it.
|
|
124
|
+
if (!/const ALLOW_SECRETS = process\.env\.LATTICE_ALLOW_SECRET_VALUES === "1"/.test(source)) {
|
|
125
|
+
fail("the masking opt-out is not the expected LATTICE_ALLOW_SECRET_VALUES === \"1\" check — masking may no longer default to on");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Report ───────────────────────────────────────────────────────────────────
|
|
129
|
+
if (failures.length > 0) {
|
|
130
|
+
console.error("verification failed:\n");
|
|
131
|
+
for (const f of failures) console.error(` ✗ ${f}`);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.log(`✓ index.js parses`);
|
|
136
|
+
console.log(`✓ version ${pkg.version} consistent across package.json, index.js and AGENTS.md`);
|
|
137
|
+
console.log(`✓ ${toolCount} tools registered, no duplicates, all lattice_-prefixed`);
|
|
138
|
+
console.log(`✓ README.md and AGENTS.md agree on the tool count`);
|
|
139
|
+
console.log(`✓ every tool appears in the README tool tables`);
|
|
140
|
+
console.log(`✓ sanitise() is wired into api() and masking defaults to on`);
|