@dzhechkov/cloudru-hub 0.1.15 → 0.1.17
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/.dz-manifest.json +27 -19
- package/CHANGELOG.md +42 -2
- package/README.md +84 -15
- package/bin/cloudru-hub.js +0 -0
- package/data/pointer-section.md +1 -1
- package/data/tools-classification.json +13 -3
- package/data/tools-list-snapshot.json +12 -4
- package/docs/HERMES-CODEX.md +4 -4
- package/docs/LOCAL-TESTING.md +17 -9
- package/docs/WEB-IMAGE.md +57 -0
- package/package.json +8 -8
- package/sbom.json +38 -18
- package/src/cli.js +3 -2
- package/src/install.js +1 -1
- package/src/k8s-approval.js +9 -6
- package/src/k8s-image-manifest.js +75 -0
- package/src/safe-mcp.js +27 -12
- package/src/toolsets.js +1 -1
- package/templates/.claude/hooks/cloudru-ssh-guard.cjs +0 -0
- package/test/classification.test.mjs +5 -5
- package/test/deploy-approval.test.mjs +12 -0
- package/test/k8s-approval.test.mjs +40 -0
- package/test/resolve.test.mjs +1 -1
- package/test/safe-mcp.test.mjs +20 -0
package/.dz-manifest.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"files": [
|
|
6
6
|
{
|
|
7
7
|
"path": "CHANGELOG.md",
|
|
8
|
-
"sha256": "
|
|
8
|
+
"sha256": "70bc9263f657739464a56bf220ce370da7baa73dae086565417ccf62b6806078"
|
|
9
9
|
},
|
|
10
10
|
{
|
|
11
11
|
"path": "LICENSE",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
15
|
"path": "README.md",
|
|
16
|
-
"sha256": "
|
|
16
|
+
"sha256": "0ca9b07e9194d91645194140c23d715b741a4503ad261305bf1b8af28470c494"
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
"path": "THIRD_PARTY_NOTICES.md",
|
|
@@ -29,15 +29,15 @@
|
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
"path": "data/pointer-section.md",
|
|
32
|
-
"sha256": "
|
|
32
|
+
"sha256": "32845b9cc1ad64f2f6a8d4eeb8b90bf36710590d25db4234e6813793ef63c5d4"
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
35
|
"path": "data/tools-classification.json",
|
|
36
|
-
"sha256": "
|
|
36
|
+
"sha256": "0f09cb2fffd2feebfa5e7e97e8503a47ae1b63d1035e309540a4211f0bace477"
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
"path": "data/tools-list-snapshot.json",
|
|
40
|
-
"sha256": "
|
|
40
|
+
"sha256": "247fe8a6d9c47d3b7c4d218d1047df842e41e3b1880594d7dbc7b3f5e6ce52ed"
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
"path": "docs/COST-COMPARE.md",
|
|
@@ -45,11 +45,15 @@
|
|
|
45
45
|
},
|
|
46
46
|
{
|
|
47
47
|
"path": "docs/HERMES-CODEX.md",
|
|
48
|
-
"sha256": "
|
|
48
|
+
"sha256": "00a5c9acc3999c5e07f230ba63bc1a0e202d62c8d15d2204620feca762eece8f"
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"path": "docs/LOCAL-TESTING.md",
|
|
52
|
-
"sha256": "
|
|
52
|
+
"sha256": "6b2f6b18f863a9a467ad08977b14de4fdf3285a70ae5a35ce855c4f143c05958"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"path": "docs/WEB-IMAGE.md",
|
|
56
|
+
"sha256": "690e092bafddc862c20ebd5ff0358a4c57f84881153c235a146e258563459f27"
|
|
53
57
|
},
|
|
54
58
|
{
|
|
55
59
|
"path": "docs/cost-compare.example.json",
|
|
@@ -57,7 +61,7 @@
|
|
|
57
61
|
},
|
|
58
62
|
{
|
|
59
63
|
"path": "package.json",
|
|
60
|
-
"sha256": "
|
|
64
|
+
"sha256": "7fe766354e40487c5dd90461d41edcde781cf4775d46376daad9c5abbaf8b314"
|
|
61
65
|
},
|
|
62
66
|
{
|
|
63
67
|
"path": "src/classification.js",
|
|
@@ -65,7 +69,7 @@
|
|
|
65
69
|
},
|
|
66
70
|
{
|
|
67
71
|
"path": "src/cli.js",
|
|
68
|
-
"sha256": "
|
|
72
|
+
"sha256": "590265903b3a242b3d9c1367801ce6ce10e23e18abbc28938c5a26f735ea7d75"
|
|
69
73
|
},
|
|
70
74
|
{
|
|
71
75
|
"path": "src/cost-compare.js",
|
|
@@ -85,11 +89,15 @@
|
|
|
85
89
|
},
|
|
86
90
|
{
|
|
87
91
|
"path": "src/install.js",
|
|
88
|
-
"sha256": "
|
|
92
|
+
"sha256": "90af414aabda1c48c82c9c2ecf0dbe4d7dd4744486efdce0d8367182b6a67a38"
|
|
89
93
|
},
|
|
90
94
|
{
|
|
91
95
|
"path": "src/k8s-approval.js",
|
|
92
|
-
"sha256": "
|
|
96
|
+
"sha256": "950543b85a7ed2c8e1660b40c2bb50192663e5fe62f8287b9b76ac3f8178c263"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"path": "src/k8s-image-manifest.js",
|
|
100
|
+
"sha256": "8a2c519e9bc6677c867758f4b9b56146f2832ee07a5bfd4fefed3317f0d9cf73"
|
|
93
101
|
},
|
|
94
102
|
{
|
|
95
103
|
"path": "src/probe-child.js",
|
|
@@ -101,11 +109,11 @@
|
|
|
101
109
|
},
|
|
102
110
|
{
|
|
103
111
|
"path": "src/safe-mcp.js",
|
|
104
|
-
"sha256": "
|
|
112
|
+
"sha256": "60a5db44db9518835f98f23aa104b86c3621f0b0c030196c2130715f5c19c219"
|
|
105
113
|
},
|
|
106
114
|
{
|
|
107
115
|
"path": "src/toolsets.js",
|
|
108
|
-
"sha256": "
|
|
116
|
+
"sha256": "0a66e73476d5f5624908f05f621b150efa51217fc9418938ca126dc0dc2b9518"
|
|
109
117
|
},
|
|
110
118
|
{
|
|
111
119
|
"path": "templates/.claude/hooks/cloudru-ssh-guard.cjs",
|
|
@@ -117,7 +125,7 @@
|
|
|
117
125
|
},
|
|
118
126
|
{
|
|
119
127
|
"path": "test/classification.test.mjs",
|
|
120
|
-
"sha256": "
|
|
128
|
+
"sha256": "413ddf6d9a4f709d80bce9011094d95bbe56f12c391f72be235c4482a1b40450"
|
|
121
129
|
},
|
|
122
130
|
{
|
|
123
131
|
"path": "test/cost-compare.test.mjs",
|
|
@@ -125,7 +133,7 @@
|
|
|
125
133
|
},
|
|
126
134
|
{
|
|
127
135
|
"path": "test/deploy-approval.test.mjs",
|
|
128
|
-
"sha256": "
|
|
136
|
+
"sha256": "3aaf3cdf27c57be9121d7d1727335d0a97064659b3201d34d1e27f5aed631434"
|
|
129
137
|
},
|
|
130
138
|
{
|
|
131
139
|
"path": "test/dialects.test.mjs",
|
|
@@ -141,7 +149,7 @@
|
|
|
141
149
|
},
|
|
142
150
|
{
|
|
143
151
|
"path": "test/k8s-approval.test.mjs",
|
|
144
|
-
"sha256": "
|
|
152
|
+
"sha256": "acc30164bbafc2d46b404ff8ae87caf42e301e15dd005c424654ee0225af6efd"
|
|
145
153
|
},
|
|
146
154
|
{
|
|
147
155
|
"path": "test/mutation-registry-freshness.test.mjs",
|
|
@@ -157,13 +165,13 @@
|
|
|
157
165
|
},
|
|
158
166
|
{
|
|
159
167
|
"path": "test/resolve.test.mjs",
|
|
160
|
-
"sha256": "
|
|
168
|
+
"sha256": "dcaede3dea29d98d93f3c902b8f9f389f24dc7b9a84665d1c206263915282f06"
|
|
161
169
|
},
|
|
162
170
|
{
|
|
163
171
|
"path": "test/safe-mcp.test.mjs",
|
|
164
|
-
"sha256": "
|
|
172
|
+
"sha256": "e99200ccdd8368260af18532478addc08e3b6195986cecf68b669e9dc639d119"
|
|
165
173
|
}
|
|
166
174
|
]
|
|
167
175
|
},
|
|
168
|
-
"signature": "
|
|
176
|
+
"signature": "H9IFrg1rkdQ36/YEY4aH9uYXFczQB7h+PSmA5LV+mwEkF08uYlis6QAArIaDBpz6d85no/YvOS97A2rneTFbDw=="
|
|
169
177
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,13 +1,53 @@
|
|
|
1
1
|
# Changelog — @dzhechkov/cloudru-hub
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.1.17 — 2026-10-29
|
|
4
|
+
|
|
5
|
+
- Pin the matched Linux/x64 engine `0.2.8-20261029` (SHA-256
|
|
6
|
+
`0095d3911da7e3a93f1d2ee064708820b9b9c50bfc5cbd99fc46c7da6db8be08`)
|
|
7
|
+
as the exact `@dzhechkov/cloudru-vm-linux-x64@0.2.8` optional dependency.
|
|
8
|
+
The local `tools/list` observation retains the 146 ordered registered catalog
|
|
9
|
+
names and gate flags; classification permissions and tool schemas are unchanged.
|
|
10
|
+
- Add the source-reviewed, exclusive shared-local project/VM-name claim before
|
|
11
|
+
VM or static-site security-group mutation. Retain uncertain, partial and empty
|
|
12
|
+
claims across restart; no expiry, unlock or blind replay. Different roots,
|
|
13
|
+
hosts, older clients and direct API calls are outside coordination.
|
|
14
|
+
- Add action-scoped, immutable configured-project/exact-cluster delete intent
|
|
15
|
+
before `k8s_delete`. Duplicate or uncertain attempts refuse another DELETE.
|
|
16
|
+
Receipt recovery is GET-only; accepted operation and operation completion do
|
|
17
|
+
not prove cluster absence or cleanup. Existing create/nodepool behavior remains.
|
|
18
|
+
Both corrections have local synthetic controls, not new paid-cloud acceptance.
|
|
19
|
+
|
|
20
|
+
## 0.1.16 — 2026-10-29
|
|
21
|
+
|
|
22
|
+
- Pin the matched Linux/x64 engine `0.2.7-20261029` (SHA-256
|
|
23
|
+
`94fdba4fe4d1a71e684be998c3d2f94f374ff4e4054db2683bcc04d72bfabc03`).
|
|
24
|
+
Kubernetes `k8s_plan(preflight:true)` is included and reports time/project-bound
|
|
25
|
+
observed checks, exact same-project name
|
|
26
|
+
conflicts and explicit unknowns for inventory completeness, create grants,
|
|
27
|
+
quota capacity, integration settings and tariffs. It always reports
|
|
28
|
+
`ready_to_apply:false`; it does not authorize create or promise readiness.
|
|
29
|
+
|
|
30
|
+
- Include placement resolution and durable Kubernetes create/nodepool intent
|
|
31
|
+
receipts in the matched engine. `k8s_get` can observe `receipt_id`,
|
|
32
|
+
`operation_id` and bounded `receipts:true` discovery after a lost reply.
|
|
33
|
+
No automatic retry is unlocked, and this candidate has no new live
|
|
34
|
+
Kubernetes deployment or `web-image` acceptance result.
|
|
4
35
|
|
|
5
36
|
- Add `cost-compare --input <json> [--json]`, an offline VM/Kubernetes cost worksheet
|
|
6
37
|
with explicit topology, dated tariff provenance, exact decimal sums and per-node
|
|
7
38
|
normal/failure capacity bounds. Incomplete prices, insufficient capacity or unknown
|
|
8
39
|
architecture equivalence suppress a winner. The packaged official-rate example
|
|
9
40
|
leaves traffic and equivalence unknown. See `docs/COST-COMPARE.md` for the schema
|
|
10
|
-
and limits.
|
|
41
|
+
and limits.
|
|
42
|
+
|
|
43
|
+
- Retain read-only `deploy_reconcile` and
|
|
44
|
+
`deploy_preflight`. Ordinary VM apply journals intent before create; an
|
|
45
|
+
ambiguous response requires observation before any retry. A new plan cannot
|
|
46
|
+
reuse the project and VM name of an earlier run receipt on sequential submission;
|
|
47
|
+
concurrent distinct VM plans are not serialized.
|
|
48
|
+
- Add a separate `web-image` Kubernetes approval profile for a reviewed,
|
|
49
|
+
digest-pinned one-pod web workload in an existing namespace. Source build,
|
|
50
|
+
registry publication and live host acceptance remain separate steps.
|
|
11
51
|
|
|
12
52
|
## 0.1.14 — 2026-10-20
|
|
13
53
|
|
package/README.md
CHANGED
|
@@ -2,28 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
Site: https://aicoding.space · Source: https://github.com/djd1m/dz-harness/tree/main/packages/cloudru-hub
|
|
4
4
|
|
|
5
|
-
> **Linux/x64
|
|
6
|
-
> package `@dzhechkov/cloudru-vm-linux-x64@0.2.
|
|
5
|
+
> **Linux/x64:** version 0.1.17 names the exact optional engine
|
|
6
|
+
> package `@dzhechkov/cloudru-vm-linux-x64@0.2.8`. The `dz guard` `licence-hold`
|
|
7
7
|
> rule continues to check LICENSE, notices and SPDX on publish.
|
|
8
8
|
|
|
9
9
|
> **Unofficial. NOT affiliated with, endorsed, or supported by Cloud.ru.** "Cloud.ru" is a
|
|
10
10
|
> trademark of its owner; this launcher merely talks to the public Cloud.ru Evolution API
|
|
11
11
|
> through the `cloudru-vm` engine.
|
|
12
12
|
|
|
13
|
-
Thin JS launcher for the **`cloudru-vm` MCP engine** — a Go MCP server with **
|
|
14
|
-
(MEASURED —
|
|
13
|
+
Thin JS launcher for the **`cloudru-vm` MCP engine** — a Go MCP server with **146 registered catalog tools**
|
|
14
|
+
(MEASURED — local `tools/list` probe, `data/tools-list-snapshot.json`; this is not 146 live Cloud.ru API tests) for Cloud.ru
|
|
15
15
|
Evolution: VMs, k8s, PostgreSQL/Kafka/Redis, DNS, S3, certificates, billing/FinOps, and a
|
|
16
16
|
docker-compose deploy wizard.
|
|
17
17
|
|
|
18
|
-
The launcher archive contains no engine binary.
|
|
19
|
-
|
|
18
|
+
The launcher archive contains no engine binary. npm installs the exact optional
|
|
19
|
+
platform package with the pinned Go engine on Linux/x64. Run:
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
npm install -g @dzhechkov/cloudru-hub@0.1.
|
|
22
|
+
npm install -g @dzhechkov/cloudru-hub@0.1.17
|
|
23
23
|
cloudru-hub self-test
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
The self-test verifies the engine SHA-256, version,
|
|
26
|
+
The self-test verifies the engine SHA-256, version, 146 registered MCP catalog tools and their
|
|
27
27
|
classification without Cloud.ru credentials. On another platform, or if optional
|
|
28
28
|
dependencies were disabled, provide a trusted binary with the same SHA-256.
|
|
29
29
|
|
|
@@ -38,7 +38,7 @@ The JS launcher remains MIT-licensed; its archive contains no Go engine bytes.
|
|
|
38
38
|
| Piece | Where | What it does |
|
|
39
39
|
|---|---|---|
|
|
40
40
|
| Launcher | `bin/cloudru-hub.js` | resolves the engine binary at **runtime** (never bundled): `$CLOUDRU_VM_BIN` → `~/.cloudru-hub/config.json` `enginePath` → optional platform package `@dzhechkov/cloudru-vm-linux-x64`; verifies sha256 against the pin in `package.json.cloudruHub.binaryHashes` |
|
|
41
|
-
| Paid-step brake (ADR-004) | `src/install.js`, `templates/.claude/hooks/` | emits `permissions` **ask** rules for every mutating-or-shell-reaching tool (64 tools — MEASURED, `data/tools-classification.json` counts), **deny** for `secret_value`/`cert_private_key`, **allow** for
|
|
41
|
+
| Paid-step brake (ADR-004) | `src/install.js`, `templates/.claude/hooks/` | emits `permissions` **ask** rules for every mutating-or-shell-reaching tool (64 tools — MEASURED, `data/tools-classification.json` counts), **deny** for `secret_value`/`cert_private_key`, **allow** for 80 behaviour-audited read-only tools, in BOTH `mcp__cloudru-vm__`/`mcp__cloudru_vm__` spellings (prefix normalization not yet live-probed), plus a PreToolUse veto hook (ssh-guard + the kubectl `rollout` hole) — and **executes** the veto probe before declaring install success |
|
|
42
42
|
| Target integration | `src/install.js`, `src/safe-mcp.js` | Codex, OpenCode and Pi get project MCP configs using a pinned proxy; Hermes gets a guarded `$HERMES_HOME/config.yaml` entry with a verified engine path. Claude Code's general install keeps its permissions and hook probe. The opt-in `vm-deploy` profile requires an exact-plan approval and confines remote commands to the returned VM. Codex, Hermes, OpenCode and Pi each completed both live static-site scenarios on 2026-10-20 with HTTP byte checks and exact resource cleanup. The `k8s-deploy` profile uses a separate exact-manifest approval and create-only Kubernetes operation; the same four hosts completed live Kubernetes deployment, readiness, exact-byte HTTP and cleanup checks. Claude Code has installer/registration checks but no live inference pass. |
|
|
43
43
|
| Skill dialect compiler (ADR-006) | `src/dialects.js`, `data/dialects.json` | generates per-target variants of the canonical skill tree (which is NOT shipped — point `--canonical` at an engine distribution); hard-fails if any Hermes-dialect token survives AND if the engine codename survives in any case in any emitted file (positive invariant, not just the enumerated denylist); unlinks dangling links; enforces the 12 000-char router cap on lossy layouts; byte-deterministic |
|
|
44
44
|
| Golden classification | `data/tools-classification.json` | every engine tool → allow/ask/deny; `allow` requires a BEHAVIOUR audit of the handler (read-only proven at the pinned baseline, engine file:line cited per entry). The previous `stack_status` behavior was unsafe; the imported engine now makes it observational and tests that invariant. A live tool unknown to the classifier is a drift finding. |
|
|
@@ -54,8 +54,9 @@ cloudru-hub mcp # run the engine as an MCP stdio server (wha
|
|
|
54
54
|
cloudru-hub mcp-safe --toolsets inventory # pinned MCP proxy with a small allow-classified surface
|
|
55
55
|
cloudru-hub install --target codex --toolsets billing # read-only balance, consumption and FinOps
|
|
56
56
|
cloudru-hub install --target codex --toolsets vm-deploy # guarded VM deployment profile
|
|
57
|
-
cloudru-hub install --target codex --toolsets k8s-deploy # bounded existing-cluster
|
|
57
|
+
cloudru-hub install --target codex --toolsets k8s-deploy # bounded existing-cluster profile
|
|
58
58
|
cloudru-hub k8s-approve --cluster-id <uuid> --namespace snake-pilot --manifest <snake.yaml> --manifest-sha256 <digest>
|
|
59
|
+
cloudru-hub k8s-approve --cluster-id <uuid> --namespace lesson-app --manifest <web.yaml> --manifest-sha256 <digest> --profile web-image
|
|
59
60
|
cloudru-hub approve --plan-id <id> --max-monthly-rub <cap> # one reviewed SSH plan
|
|
60
61
|
cloudru-hub approve --plan-id <id> --max-monthly-rub <cap> --site-sha256 <digest> # reviewed static-site artifact
|
|
61
62
|
cloudru-hub install --target claude-code # emit .mcp.json + permission brake + veto hook, PROBE the veto
|
|
@@ -81,7 +82,7 @@ node bin/cloudru-hub.js self-test
|
|
|
81
82
|
```
|
|
82
83
|
|
|
83
84
|
Expected: 5 green steps ending `self-test: ALL GREEN` (resolution, sha256 match against
|
|
84
|
-
the pinned baseline `
|
|
85
|
+
the pinned baseline `0095d391…db8be08`, engine version, 146 registered catalog tools, full
|
|
85
86
|
classification coverage). Full walkthrough incl. `npm link` and MCP registration:
|
|
86
87
|
[`docs/LOCAL-TESTING.md`](docs/LOCAL-TESTING.md).
|
|
87
88
|
|
|
@@ -89,9 +90,77 @@ Host setup, checks and limits: [`docs/HERMES-CODEX.md`](docs/HERMES-CODEX.md). T
|
|
|
89
90
|
|
|
90
91
|
For billing through `mcp-safe`, add the optional `"agreement_id":"…"` to the owner-only `~/.config/cloudru-hub/credentials.json` alongside `key_id`, `secret` and `project_id`. The proxy forwards it to the Go child as `CLOUDRU_AGREEMENT_ID`. Existing environment-only billing setups remain supported when no credential file is configured. The current consumption tool reports the agreement-wide total; its output should not be presented as a per-project bill. See the [Cloud.ru instructions for locating the agreement ID](https://cloud.ru/docs/billing/ug/topics/faq__api_id_agreement).
|
|
91
92
|
|
|
92
|
-
The `vm-deploy` and `k8s-deploy` profiles are
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
The `vm-deploy` and `k8s-deploy` profiles are opt-in previews requiring a matching pinned Go binary and credentials in a private local file. `deploy_plan` resolves a VM and a compute/disk estimate; `deploy_preflight(plan_id)` observes live name collisions and available quota usage, naming unknown create rights, capacity and billed tariffs. After an exact-plan approval, `deploy_apply` writes a private run intent before VM creation. If the API outcome is uncertain, `deploy_reconcile(plan_id)` reads the receipt and project inventory; it never adopts a same-name VM or authorizes a retry. A prior receipt for the same project and VM name also blocks a new plan, even if the cloud inventory has not caught up; inspect the prior run and choose a new name. The static-site route still snapshots approved HTML/ZIP bytes, uses fixed Cloud-init and a dedicated TCP-80 group, and reports verified only after exact-byte HTTP checks against the approved IP. Every public file must be reviewed; a public IP may incur additional charges. Static sites do not grant `vm_exec` SSH access.
|
|
94
|
+
|
|
95
|
+
**Published versions `cloudru-hub@0.1.16` and `cloudru-vm-linux-x64@0.2.7`:** concurrent distinct VM plans for the same project and VM name are not serialized. Avoid launching those plans together; the prior-receipt check protects sequential submissions and is not an atomic name reservation.
|
|
96
|
+
|
|
97
|
+
**Matched `cloudru-hub@0.1.17` / `cloudru-vm-linux-x64@0.2.8`:** the initial deployment checkpoint exclusively claims the exact project/VM-name pair in private shared local state before either a VM or static-site security group is mutated. The claim remains after process exit, an uncertain provider response, or a later checkpoint failure; even an empty or partial claim blocks another plan. There is no TTL, unlock, forced replay or name-based adoption. Inspect the retained claim and run receipt in the shared state root, reconcile live project inventory and known resource IDs, then choose a new name for a separate deployment if needed. Coordination covers upgraded processes sharing one local state root. Old clients, raw engine paths, different roots or hosts, and direct Cloud.ru API calls remain outside it; this is not distributed idempotency. Local synthetic race, restart and I/O-fault controls cover this correction; no new live VM creation was used for these controls.
|
|
98
|
+
|
|
99
|
+
In the previous `0.2.7-20261029` engine (retained in `0.2.8-20261029`), operator `k8s_plan` resolves a supported zone name or ID to `zone_id`/`zones`, and `k8s_create` explicitly sends that ID for control-plane placement. The VIP subnet must match the same zone. This does not establish why a worker pool may fail to register nodes.
|
|
100
|
+
|
|
101
|
+
The matched engine also accepts `k8s_plan` with
|
|
102
|
+
`{"preflight":true}` to collect read-only observations before a possible paid
|
|
103
|
+
cluster creation. It is separate from VM `deploy_preflight(plan_id)`. The
|
|
104
|
+
versioned report includes its observation time and configured project, with
|
|
105
|
+
`observed_checks`, `observed_conflicts` and `unknowns`. It attempts exact
|
|
106
|
+
zone/subnet resolution, checks returned project cluster rows for an exact name
|
|
107
|
+
collision, and reads project-filtered quota usage. A matching cluster with an
|
|
108
|
+
explicit same-project identity is a conflict; an empty or unmatched response
|
|
109
|
+
does not prove the name is free because inventory completeness is unknown.
|
|
110
|
+
Denied, malformed or unavailable quota responses remain unknown; usage does
|
|
111
|
+
not prove create capacity. Failed probes leave the established findings intact.
|
|
112
|
+
|
|
113
|
+
Every diagnostic report says `ready_to_apply:false`. Authentication, successful
|
|
114
|
+
listing and JWT scope roles do not prove effective caller or cluster service
|
|
115
|
+
account create grants. The [Cloud.ru cluster service-account role guide](https://cloud.ru/docs/kubernetes-evolution/ug/topics/concepts__roles-for-cluster-sa) lists
|
|
116
|
+
`k8s.user`, `nlb.user` and `vpc.user` as base dependencies; `auditlog.writer`, `logaas.writer`
|
|
117
|
+
and `monaas.write` depend on the actual audit, logging and monitoring settings,
|
|
118
|
+
which this report cannot inspect. Verify assignments and settings in IAM before
|
|
119
|
+
a separate paid-operation approval. Exact placement resolution also does not
|
|
120
|
+
prove region-wide availability or runtime readiness. Prospective tariffs are
|
|
121
|
+
not supplied: include compute, control plane, disk, public IP, SNAT, load
|
|
122
|
+
balancer and traffic, then run `cloudru-hub cost-compare --input <json> --json`
|
|
123
|
+
separately with verified tariff inputs. This diagnostic does not create an
|
|
124
|
+
approval or unlock a retry.
|
|
125
|
+
|
|
126
|
+
Kubernetes create/nodepool recovery was included in the previous `0.2.7-20261029` engine and is retained in `0.2.8-20261029`.
|
|
127
|
+
The operator flow records a private durable intent before
|
|
128
|
+
`k8s_create` or `k8s_add_nodepool`, then saves known operation/resource IDs.
|
|
129
|
+
After a lost reply, `k8s_get` supports `receipt_id`, `operation_id` and
|
|
130
|
+
bounded `receipts:true` discovery in addition to its existing cluster lookup.
|
|
131
|
+
These observations distinguish provider operation state from resource presence;
|
|
132
|
+
they do not check Ready Nodes or application HTTP. This release adds no new
|
|
133
|
+
live Kubernetes deployment or `web-image` acceptance result.
|
|
134
|
+
|
|
135
|
+
The first recovery increment keeps the same project/kind/parent/name blocked
|
|
136
|
+
after any submitted intent, including an ambiguous outcome or later deletion.
|
|
137
|
+
Changing a request's size does not permit another submission. There is no
|
|
138
|
+
automatic retry, force flag or receipt-removal procedure. A different name is a
|
|
139
|
+
separate deployment with possible duplicate cost. Diagnostics do not create a
|
|
140
|
+
cluster, and existing guarded profiles still deny infrastructure create tools.
|
|
141
|
+
|
|
142
|
+
**Matched `cloudru-hub@0.1.17` / `cloudru-vm-linux-x64@0.2.8`:**
|
|
143
|
+
before one confirmed `k8s_delete` DELETE, the engine writes a private, durable
|
|
144
|
+
`cluster-delete` intent scoped to the action, configured project and exact
|
|
145
|
+
cluster ID. That target is immutable. A duplicate call or restart after an
|
|
146
|
+
uncertain outcome refuses another DELETE through the shared store, including
|
|
147
|
+
when the receipt is unreadable. A valid accepted response reports `deleting`;
|
|
148
|
+
malformed or uncertain responses report `unknown_outcome` and retain only
|
|
149
|
+
unambiguous operation correlation. For `unknown_outcome` or
|
|
150
|
+
`recovery_required`, inspect `k8s_get(receipt_id)` or bounded `receipts:true`
|
|
151
|
+
discovery. Recovery is GET-only for the exact target and any known operation
|
|
152
|
+
under the configured project. A scoped exact-cluster 404 observes absence;
|
|
153
|
+
403, errors, malformed or mismatched JSON remain unknown. Operation `done`
|
|
154
|
+
does not prove cluster absence, application readiness, dependent cleanup or
|
|
155
|
+
zero cost. There is no TTL, receipt removal, forced retry or distributed
|
|
156
|
+
guarantee; other hosts, state roots and direct API callers do not coordinate.
|
|
157
|
+
Existing create and nodepool recovery behavior is retained. Local synthetic
|
|
158
|
+
replay, restart and I/O-fault controls cover this correction; no new paid-cluster
|
|
159
|
+
test was used. Broader operation recovery remains work in progress.
|
|
160
|
+
|
|
161
|
+
The Kubernetes preview has two explicit profiles. The default `snake` profile retains its exact ConfigMap, non-root Deployment and ClusterIP Service. `web-image` accepts one reviewed digest-pinned OCI image in a bounded one-pod Deployment and internal ClusterIP Service. It requires a `/health` endpoint on port 8080, a dedicated namespace, non-root UID 10001, a read-only root filesystem and bounded CPU/RAM. The image must already be built, pushed and pullable by the existing cluster. This package does not build code from a repository or prove that an image digest corresponds to a source commit. `k8s-approve` binds exact manifest bytes, cluster, namespace, project and a ten-minute single-use approval. The proxy uses `create_only=true`; inspect partial resources before retrying because Kubernetes multi-document create is not transactional. Neither profile creates a cluster, namespace, nodes, NAT, ingress or load balancer. Earlier four-host live application acceptance covered only Snake; `web-image` currently has deterministic local tests.
|
|
162
|
+
|
|
163
|
+
See the [web-image procedure](docs/WEB-IMAGE.md) for authored code and pinned repository inputs.
|
|
95
164
|
|
|
96
165
|
## Safety properties (all layer-1, discrimination-proven)
|
|
97
166
|
|
|
@@ -134,7 +203,7 @@ protections proven:
|
|
|
134
203
|
|
|
135
204
|
## Platform package status
|
|
136
205
|
|
|
137
|
-
The separate `@dzhechkov/cloudru-vm-linux-x64@0.2.
|
|
206
|
+
The separate `@dzhechkov/cloudru-vm-linux-x64@0.2.8` package is installed
|
|
138
207
|
automatically on Linux/x64 unless optional dependencies are disabled. Its static
|
|
139
208
|
build reproduces the pinned SHA-256 and bundles the license and NOTICE files
|
|
140
209
|
of linked Go modules. `CLOUDRU_VM_BIN` and `~/.cloudru-hub/config.json` remain
|
package/bin/cloudru-hub.js
CHANGED
|
File without changes
|
package/data/pointer-section.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Cloud.ru Evolution — указатель (pointer-only таргет)
|
|
2
2
|
|
|
3
3
|
Этот инструмент НЕ несёт на данном таргете ни MCP-тулов Cloud.ru, ни исполняемых
|
|
4
|
-
инструкций деплоя. Полная связка (MCP-сервер `cloudru-vm` на
|
|
4
|
+
инструкций деплоя. Полная связка (MCP-сервер `cloudru-vm` на 146 тулов + скилл +
|
|
5
5
|
детерминированный тормоз платных шагов) доставляется только на таргеты, где её можно
|
|
6
6
|
поставить честно: Claude Code (полный путь) и codex (после живого veto-probe).
|
|
7
7
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "Golden classification of every cloudru-vm engine tool. allow requires a BEHAVIOUR audit of the handler at the pinned engine SHA proving both axes: (1) observational with no cloud mutation, job advancement or secret return (a local plan write is disclosed), and (2) no user-controlled argument reaching shell/exec/SSH. Shell-reaching tools remain ask even when arguments are quoted. Schema shape alone never proves allow. The old baseline had two hazards: stack_status advanced planned jobs into paid actions and logs inserted an unescaped service into a shell command. In the pinned engine, stack_status reads persisted state only; stack_apply explicitly advances jobs, and logs shell-quotes service. The specific regression tests are in go/cloudru-vm/internal/mcp/stack_status_test.go and internal/engine/logs_test.go. Any unknown live tool fails drift checks; deny by default.",
|
|
3
|
-
"engineSha256": "
|
|
4
|
-
"toolCount":
|
|
3
|
+
"engineSha256": "0095d3911da7e3a93f1d2ee064708820b9b9c50bfc5cbd99fc46c7da6db8be08",
|
|
4
|
+
"toolCount": 146,
|
|
5
5
|
"counts": {
|
|
6
6
|
"ask": 64,
|
|
7
7
|
"deny": 2,
|
|
8
|
-
"allow":
|
|
8
|
+
"allow": 80
|
|
9
9
|
},
|
|
10
10
|
"tools": {
|
|
11
11
|
"deploy": {
|
|
@@ -73,6 +73,16 @@
|
|
|
73
73
|
"permission": "ask",
|
|
74
74
|
"why": "engine schema declares confirmed prop (gated upstream; belt-plus-braces per ADR-004 §3)"
|
|
75
75
|
},
|
|
76
|
+
"deploy_reconcile": {
|
|
77
|
+
"effect": "read-only",
|
|
78
|
+
"permission": "allow",
|
|
79
|
+
"why": "behaviour-audited read-only: handleDeployReconcile (tools_deploy.go:259) reads a local receipt and project-scoped VM inventory; ReconcileRun only compares observed identities and never writes state or reaches shell."
|
|
80
|
+
},
|
|
81
|
+
"deploy_preflight": {
|
|
82
|
+
"effect": "read-only",
|
|
83
|
+
"permission": "allow",
|
|
84
|
+
"why": "behaviour-audited read-only: handleDeployPreflight (tools_deploy.go:278) reads a saved plan, VM inventory and quota usage with HTTP GET; EvaluatePreflight does not create cloud resources, write state or reach shell."
|
|
85
|
+
},
|
|
76
86
|
"list_flavors": {
|
|
77
87
|
"effect": "read-only",
|
|
78
88
|
"permission": "allow",
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"capturedAt": "2026-10-
|
|
3
|
-
"engineVersion": "cloudru-vm 0.2.
|
|
4
|
-
"engineSha256": "
|
|
2
|
+
"capturedAt": "2026-10-29",
|
|
3
|
+
"engineVersion": "cloudru-vm 0.2.8-20261029",
|
|
4
|
+
"engineSha256": "0095d3911da7e3a93f1d2ee064708820b9b9c50bfc5cbd99fc46c7da6db8be08",
|
|
5
5
|
"method": "JSON-RPC tools/list via src/probe-child.js against locally built private engine",
|
|
6
|
-
"toolCount":
|
|
6
|
+
"toolCount": 146,
|
|
7
7
|
"tools": [
|
|
8
8
|
{
|
|
9
9
|
"name": "deploy",
|
|
@@ -57,6 +57,14 @@
|
|
|
57
57
|
"name": "deploy_apply",
|
|
58
58
|
"gated": true
|
|
59
59
|
},
|
|
60
|
+
{
|
|
61
|
+
"name": "deploy_reconcile",
|
|
62
|
+
"gated": false
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"name": "deploy_preflight",
|
|
66
|
+
"gated": false
|
|
67
|
+
},
|
|
60
68
|
{
|
|
61
69
|
"name": "list_flavors",
|
|
62
70
|
"gated": false
|
package/docs/HERMES-CODEX.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Cloud.ru tools in Codex, Hermes, OpenCode and Pi
|
|
2
2
|
|
|
3
|
-
`cloudru-hub mcp-safe` is the common process boundary. It starts only the binary whose SHA-256 matches the package pin and the classification baseline. Its default profile advertises the
|
|
3
|
+
`cloudru-hub mcp-safe` is the common process boundary. It starts only the binary whose SHA-256 matches the package pin and the classification baseline. Its default profile advertises the 80 golden `allow` tools and rejects `ask`, `deny`, unknown tools and unsupported MCP methods before forwarding a call. Some planning tools may still write local plan files. Filesystem and shell access granted separately to an agent remain outside this MCP boundary.
|
|
4
4
|
|
|
5
|
-
Installers default to the smaller `inventory` toolset (11 tools). Select comma-separated groups with `--toolsets inventory,billing`; the available groups are `inventory`, `compute`, `kubernetes`, `data`, `network`, `storage`, and `billing`. `--toolsets all` exposes all
|
|
5
|
+
Installers default to the smaller `inventory` toolset (11 tools). Select comma-separated groups with `--toolsets inventory,billing`; the available groups are `inventory`, `compute`, `kubernetes`, `data`, `network`, `storage`, and `billing`. `--toolsets all` exposes all 80 allow-classified tools. The proxy checks the chosen set on every `tools/call`, so a tool hidden from discovery cannot be invoked directly.
|
|
6
6
|
|
|
7
7
|
For the read-only billing tools, put `agreement_id` in the same owner-only credential file as `key_id`, `secret` and `project_id`, then select `--toolsets billing` or `inventory,billing`. The safe proxy passes `agreement_id` to the Go engine as `CLOUDRU_AGREEMENT_ID`; Codex installation inherits the credential-file path and billing environment variables. If the file is absent, an existing environment-only setup still works. An explicitly configured but missing or invalid file is an error. `billing_consumption` currently returns an agreement-wide aggregate. The Cloud.ru API documents project filtering, but this adapter has not implemented or live-verified that filter; do not label its total as a project-only charge.
|
|
8
8
|
|
|
@@ -10,11 +10,11 @@ The separate `--toolsets vm-deploy` profile allows one reviewed VM plan and remo
|
|
|
10
10
|
|
|
11
11
|
This local approval is an operator workflow, not a hard security boundary against an agent with unrestricted shell access as the same Unix user: such an agent could invoke `approve` or edit the local files itself. Use process/user isolation and cloud IAM limits if the deployment agent is untrusted. The cost number is an estimate, and the operator cap controls the reviewed plan, not Cloud.ru's final bill.
|
|
12
12
|
|
|
13
|
-
The launcher archive does **not bundle** the engine. A Linux/x64 npm install pulls `@dzhechkov/cloudru-vm-linux-x64@0.2.
|
|
13
|
+
The launcher archive does **not bundle** the engine. A Linux/x64 npm install pulls `@dzhechkov/cloudru-vm-linux-x64@0.2.7` as an exact optional dependency. On other platforms, or when optional dependencies are disabled, set `CLOUDRU_VM_BIN` to a trusted copy whose hash matches `package.json.cloudruHub.binaryHashes`. Run `cloudru-hub self-test --json` first. A missing or changed binary makes `mcp-safe` refuse startup.
|
|
14
14
|
|
|
15
15
|
| Host | Setup | Activation check |
|
|
16
16
|
|---|---|---|
|
|
17
|
-
| Codex | `cloudru-hub install --target codex --dir /trusted/project --toolsets vm-deploy` writes `.codex/config.toml` with `required=true`,
|
|
17
|
+
| Codex | `cloudru-hub install --target codex --dir /trusted/project --toolsets vm-deploy` writes `.codex/config.toml` with `required=true`, 14 enabled tools, an explicit 1200-second timeout and inherited paths for the pinned engine and credentials file | Trust the project; in a fresh session call local-only `status` before planning. The profile requests host `auto`, but `codex exec` with approval policy `never` blocked even `status` in the local probe; `--approve-for-me` allowed it. The proxy still requires a separate plan-bound approval for `deploy_apply` |
|
|
18
18
|
| OpenCode | `cloudru-hub install --target opencode --dir /trusted/project` writes `opencode.json` with a local server | `opencode mcp list`, then inspect tools in a session; installer refuses an existing `opencode.jsonc` to avoid conflicting configs |
|
|
19
19
|
| Pi | `cloudru-hub install --target pi --dir /trusted/project` writes `.pi/mcp.json` | Use a Pi release with built-in MCP, trust the project, run `pi mcp list`, then `/mcp` in a session; an older Pi may require an adapter |
|
|
20
20
|
| Hermes | With a hash-verified engine available, `cloudru-hub install --target hermes` adds a guarded entry to `$HERMES_HOME/config.yaml` with an explicit nonempty include list and `CLOUDRU_VM_BIN` path; `--hermes-home` selects a test directory | Run `hermes mcp test cloudru_hub`; complex YAML and existing Cloud.ru entries are refused for manual review |
|
package/docs/LOCAL-TESTING.md
CHANGED
|
@@ -19,8 +19,8 @@ EOF
|
|
|
19
19
|
# c) no override: the installed Linux/x64 optional platform package
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
The pinned binary identifies itself as `cloudru-vm 0.2.
|
|
23
|
-
`
|
|
22
|
+
The pinned binary identifies itself as `cloudru-vm 0.2.8-20261029` and has SHA-256
|
|
23
|
+
`0095d3911da7e3a93f1d2ee064708820b9b9c50bfc5cbd99fc46c7da6db8be08`.
|
|
24
24
|
|
|
25
25
|
## 2. Self-test (end-to-end, no credentials required)
|
|
26
26
|
|
|
@@ -29,18 +29,26 @@ cd packages/@dzhechkov/cloudru-hub
|
|
|
29
29
|
node bin/cloudru-hub.js self-test
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Example output from the verified engine (installation path varies):
|
|
33
33
|
|
|
34
34
|
```
|
|
35
35
|
✓ resolve: …/cloudru-vm-linux-x64/native/cloudru-vm (source: env)
|
|
36
|
-
✓ sha256:
|
|
37
|
-
✓ engine-version: cloudru-vm 0.2.
|
|
38
|
-
✓ tools-list:
|
|
39
|
-
✓ classification-coverage: every live tool is classified (
|
|
36
|
+
✓ sha256: 0095d3911da7e3a93f1d2ee064708820b9b9c50bfc5cbd99fc46c7da6db8be08 — MATCHES pinned baseline
|
|
37
|
+
✓ engine-version: cloudru-vm 0.2.8-20261029
|
|
38
|
+
✓ tools-list: 146 tools live (snapshot: 146)
|
|
39
|
+
✓ classification-coverage: every live tool is classified (146 golden entries)
|
|
40
40
|
self-test: ALL GREEN
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
The tool's word “live” means a real local MCP catalog observation: 146
|
|
44
|
+
registered tools, without Cloud.ru credentials or cloud API calls. It does
|
|
45
|
+
not mean 146 cloud operations were exercised. A sandbox that prohibits child
|
|
46
|
+
processes can instead report `spawnSync /usr/bin/node EPERM`; run the same
|
|
47
|
+
self-test where the engine can start, rather than treating that refusal as a pass.
|
|
48
|
+
|
|
49
|
+
For `--toolsets vm-deploy`, place
|
|
50
|
+
temporary keys in the owner-only JSON file described in `HERMES-CODEX.md`;
|
|
51
|
+
the proxy maps them to the Go child's environment without shell evaluation.
|
|
44
52
|
|
|
45
53
|
## 3. Optional: `npm link` for a global `cloudru-hub`
|
|
46
54
|
|
|
@@ -66,4 +74,4 @@ appears; note which prefix spelling the runtime used.
|
|
|
66
74
|
|
|
67
75
|
## 5. Testing from another server (`npm install` path)
|
|
68
76
|
|
|
69
|
-
On Linux/x64, install `@dzhechkov/cloudru-hub@0.1.
|
|
77
|
+
On Linux/x64, install `@dzhechkov/cloudru-hub@0.1.17` normally and run `cloudru-hub self-test` without setting `CLOUDRU_VM_BIN`. npm installs the exact `@dzhechkov/cloudru-vm-linux-x64@0.2.8` optional engine package. If optional dependencies were disabled or the platform is unsupported, provide a matching trusted binary explicitly.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Existing-cluster web-image profile
|
|
2
|
+
|
|
3
|
+
This profile applies a reviewed image to an existing Cloud.ru Kubernetes cluster. It does not create a cluster, build an image or prove source-to-image provenance. The `snake` profile remains the default and keeps its previous exact manifest contract.
|
|
4
|
+
|
|
5
|
+
## From authored code or a repository
|
|
6
|
+
|
|
7
|
+
1. For a repository, record its URL and immutable commit SHA. For authored code, record the exact source tree or release artifact used by the build.
|
|
8
|
+
2. Build and publish the image in your trusted CI. Record the resulting registry digest, not a mutable tag. Confirm that the target cluster can pull it. This package does not run an untrusted Dockerfile or store registry credentials.
|
|
9
|
+
3. Prepare a two-document YAML manifest with the image digest. Use a dedicated namespace that already exists, and ensure the app answers `GET /health` on port 8080 as UID 10001 with a read-only root filesystem. Example shape:
|
|
10
|
+
|
|
11
|
+
```yaml
|
|
12
|
+
apiVersion: apps/v1
|
|
13
|
+
kind: Deployment
|
|
14
|
+
metadata: {name: lesson-web, namespace: lesson-app}
|
|
15
|
+
spec:
|
|
16
|
+
replicas: 1
|
|
17
|
+
selector: {matchLabels: {app: lesson-web}}
|
|
18
|
+
template:
|
|
19
|
+
metadata: {labels: {app: lesson-web}}
|
|
20
|
+
spec:
|
|
21
|
+
automountServiceAccountToken: false
|
|
22
|
+
securityContext: {runAsNonRoot: true}
|
|
23
|
+
containers:
|
|
24
|
+
- name: lesson-web
|
|
25
|
+
image: registry.example.com/lesson-web@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
|
26
|
+
ports: [{containerPort: 8080}]
|
|
27
|
+
readinessProbe: {httpGet: {path: /health, port: 8080}}
|
|
28
|
+
resources:
|
|
29
|
+
requests: {cpu: 50m, memory: 64Mi}
|
|
30
|
+
limits: {cpu: 200m, memory: 256Mi}
|
|
31
|
+
securityContext:
|
|
32
|
+
allowPrivilegeEscalation: false
|
|
33
|
+
readOnlyRootFilesystem: true
|
|
34
|
+
runAsUser: 10001
|
|
35
|
+
runAsGroup: 10001
|
|
36
|
+
capabilities: {drop: [ALL]}
|
|
37
|
+
---
|
|
38
|
+
apiVersion: v1
|
|
39
|
+
kind: Service
|
|
40
|
+
metadata: {name: lesson-web, namespace: lesson-app}
|
|
41
|
+
spec:
|
|
42
|
+
type: ClusterIP
|
|
43
|
+
selector: {app: lesson-web}
|
|
44
|
+
ports: [{port: 80, targetPort: 8080}]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The registry and digest above are placeholders. Replace them with a real, pullable digest before approval. The exact byte sequence of the final manifest is what the approval binds.
|
|
48
|
+
|
|
49
|
+
4. Compute `sha256sum web.yaml`, review the file and its resource footprint, then run:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
cloudru-hub k8s-approve --cluster-id <uuid> --namespace lesson-app --manifest web.yaml --manifest-sha256 <sha256> --profile web-image
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
5. The agent calls `k8s_apply` with only the returned `approval_id`. The proxy forwards the approved manifest with `create_only=true`. Use the bounded `k8s_kubectl` queries for pods, deployment, service and rollout. An API success is not evidence of a healthy application; inspect the rollout and workload health.
|
|
56
|
+
|
|
57
|
+
Kubernetes creation across the two documents is not transactional. If one resource is created and the next fails, inspect the exact named resources before cleanup or another attempt. The profile exposes only a ClusterIP; a public endpoint requires a separate, reviewed ingress decision. Cluster and worker charges are outside this approval.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dzhechkov/cloudru-hub",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
4
4
|
"licenseHold": {
|
|
5
5
|
"adr": "features/hermes-claude-adaptation/03_adr/001-licence-and-provenance-precondition.md",
|
|
6
6
|
"note": "SATISFIED hold. Timur is credited for substantial Cloud.ru API testing. The 2026-10-20 correction is in the historical grant record. This field keeps the dz guard licence-hold rule checking LICENSE, THIRD_PARTY_NOTICES and SPDX on every publish."
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
],
|
|
27
27
|
"cloudruHub": {
|
|
28
28
|
"binaryHashes": {
|
|
29
|
-
"linux-x64": "
|
|
29
|
+
"linux-x64": "0095d3911da7e3a93f1d2ee064708820b9b9c50bfc5cbd99fc46c7da6db8be08"
|
|
30
30
|
},
|
|
31
|
-
"engineVersion": "cloudru-vm 0.2.
|
|
31
|
+
"engineVersion": "cloudru-vm 0.2.8-20261029",
|
|
32
32
|
"platformPackages": {
|
|
33
33
|
"linux-x64": "@dzhechkov/cloudru-vm-linux-x64"
|
|
34
34
|
}
|
|
@@ -53,10 +53,7 @@
|
|
|
53
53
|
"yaml": "^2.9.0"
|
|
54
54
|
},
|
|
55
55
|
"optionalDependencies": {
|
|
56
|
-
"@dzhechkov/cloudru-vm-linux-x64": "0.2.
|
|
57
|
-
},
|
|
58
|
-
"scripts": {
|
|
59
|
-
"test": "node --test 'test/*.test.mjs'"
|
|
56
|
+
"@dzhechkov/cloudru-vm-linux-x64": "0.2.8"
|
|
60
57
|
},
|
|
61
58
|
"repository": {
|
|
62
59
|
"type": "git",
|
|
@@ -69,5 +66,8 @@
|
|
|
69
66
|
},
|
|
70
67
|
"publishConfig": {
|
|
71
68
|
"access": "public"
|
|
69
|
+
},
|
|
70
|
+
"scripts": {
|
|
71
|
+
"test": "node --test 'test/*.test.mjs'"
|
|
72
72
|
}
|
|
73
|
-
}
|
|
73
|
+
}
|
package/sbom.json
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"hashes": [
|
|
16
16
|
{
|
|
17
17
|
"alg": "SHA-256",
|
|
18
|
-
"content": "
|
|
18
|
+
"content": "70bc9263f657739464a56bf220ce370da7baa73dae086565417ccf62b6806078"
|
|
19
19
|
}
|
|
20
20
|
]
|
|
21
21
|
},
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"hashes": [
|
|
36
36
|
{
|
|
37
37
|
"alg": "SHA-256",
|
|
38
|
-
"content": "
|
|
38
|
+
"content": "0ca9b07e9194d91645194140c23d715b741a4503ad261305bf1b8af28470c494"
|
|
39
39
|
}
|
|
40
40
|
]
|
|
41
41
|
},
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"hashes": [
|
|
76
76
|
{
|
|
77
77
|
"alg": "SHA-256",
|
|
78
|
-
"content": "
|
|
78
|
+
"content": "32845b9cc1ad64f2f6a8d4eeb8b90bf36710590d25db4234e6813793ef63c5d4"
|
|
79
79
|
}
|
|
80
80
|
]
|
|
81
81
|
},
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
"hashes": [
|
|
86
86
|
{
|
|
87
87
|
"alg": "SHA-256",
|
|
88
|
-
"content": "
|
|
88
|
+
"content": "0f09cb2fffd2feebfa5e7e97e8503a47ae1b63d1035e309540a4211f0bace477"
|
|
89
89
|
}
|
|
90
90
|
]
|
|
91
91
|
},
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
"hashes": [
|
|
96
96
|
{
|
|
97
97
|
"alg": "SHA-256",
|
|
98
|
-
"content": "
|
|
98
|
+
"content": "247fe8a6d9c47d3b7c4d218d1047df842e41e3b1880594d7dbc7b3f5e6ce52ed"
|
|
99
99
|
}
|
|
100
100
|
]
|
|
101
101
|
},
|
|
@@ -115,7 +115,7 @@
|
|
|
115
115
|
"hashes": [
|
|
116
116
|
{
|
|
117
117
|
"alg": "SHA-256",
|
|
118
|
-
"content": "
|
|
118
|
+
"content": "00a5c9acc3999c5e07f230ba63bc1a0e202d62c8d15d2204620feca762eece8f"
|
|
119
119
|
}
|
|
120
120
|
]
|
|
121
121
|
},
|
|
@@ -125,7 +125,17 @@
|
|
|
125
125
|
"hashes": [
|
|
126
126
|
{
|
|
127
127
|
"alg": "SHA-256",
|
|
128
|
-
"content": "
|
|
128
|
+
"content": "6b2f6b18f863a9a467ad08977b14de4fdf3285a70ae5a35ce855c4f143c05958"
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"type": "file",
|
|
134
|
+
"name": "docs/WEB-IMAGE.md",
|
|
135
|
+
"hashes": [
|
|
136
|
+
{
|
|
137
|
+
"alg": "SHA-256",
|
|
138
|
+
"content": "690e092bafddc862c20ebd5ff0358a4c57f84881153c235a146e258563459f27"
|
|
129
139
|
}
|
|
130
140
|
]
|
|
131
141
|
},
|
|
@@ -149,7 +159,7 @@
|
|
|
149
159
|
},
|
|
150
160
|
{
|
|
151
161
|
"name": "dz:canonical-json-sha256-v2",
|
|
152
|
-
"value": "
|
|
162
|
+
"value": "7fe766354e40487c5dd90461d41edcde781cf4775d46376daad9c5abbaf8b314"
|
|
153
163
|
}
|
|
154
164
|
]
|
|
155
165
|
},
|
|
@@ -169,7 +179,7 @@
|
|
|
169
179
|
"hashes": [
|
|
170
180
|
{
|
|
171
181
|
"alg": "SHA-256",
|
|
172
|
-
"content": "
|
|
182
|
+
"content": "590265903b3a242b3d9c1367801ce6ce10e23e18abbc28938c5a26f735ea7d75"
|
|
173
183
|
}
|
|
174
184
|
]
|
|
175
185
|
},
|
|
@@ -219,7 +229,7 @@
|
|
|
219
229
|
"hashes": [
|
|
220
230
|
{
|
|
221
231
|
"alg": "SHA-256",
|
|
222
|
-
"content": "
|
|
232
|
+
"content": "90af414aabda1c48c82c9c2ecf0dbe4d7dd4744486efdce0d8367182b6a67a38"
|
|
223
233
|
}
|
|
224
234
|
]
|
|
225
235
|
},
|
|
@@ -229,7 +239,17 @@
|
|
|
229
239
|
"hashes": [
|
|
230
240
|
{
|
|
231
241
|
"alg": "SHA-256",
|
|
232
|
-
"content": "
|
|
242
|
+
"content": "950543b85a7ed2c8e1660b40c2bb50192663e5fe62f8287b9b76ac3f8178c263"
|
|
243
|
+
}
|
|
244
|
+
]
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
"type": "file",
|
|
248
|
+
"name": "src/k8s-image-manifest.js",
|
|
249
|
+
"hashes": [
|
|
250
|
+
{
|
|
251
|
+
"alg": "SHA-256",
|
|
252
|
+
"content": "8a2c519e9bc6677c867758f4b9b56146f2832ee07a5bfd4fefed3317f0d9cf73"
|
|
233
253
|
}
|
|
234
254
|
]
|
|
235
255
|
},
|
|
@@ -259,7 +279,7 @@
|
|
|
259
279
|
"hashes": [
|
|
260
280
|
{
|
|
261
281
|
"alg": "SHA-256",
|
|
262
|
-
"content": "
|
|
282
|
+
"content": "60a5db44db9518835f98f23aa104b86c3621f0b0c030196c2130715f5c19c219"
|
|
263
283
|
}
|
|
264
284
|
]
|
|
265
285
|
},
|
|
@@ -269,7 +289,7 @@
|
|
|
269
289
|
"hashes": [
|
|
270
290
|
{
|
|
271
291
|
"alg": "SHA-256",
|
|
272
|
-
"content": "
|
|
292
|
+
"content": "0a66e73476d5f5624908f05f621b150efa51217fc9418938ca126dc0dc2b9518"
|
|
273
293
|
}
|
|
274
294
|
]
|
|
275
295
|
},
|
|
@@ -299,7 +319,7 @@
|
|
|
299
319
|
"hashes": [
|
|
300
320
|
{
|
|
301
321
|
"alg": "SHA-256",
|
|
302
|
-
"content": "
|
|
322
|
+
"content": "413ddf6d9a4f709d80bce9011094d95bbe56f12c391f72be235c4482a1b40450"
|
|
303
323
|
}
|
|
304
324
|
]
|
|
305
325
|
},
|
|
@@ -319,7 +339,7 @@
|
|
|
319
339
|
"hashes": [
|
|
320
340
|
{
|
|
321
341
|
"alg": "SHA-256",
|
|
322
|
-
"content": "
|
|
342
|
+
"content": "3aaf3cdf27c57be9121d7d1727335d0a97064659b3201d34d1e27f5aed631434"
|
|
323
343
|
}
|
|
324
344
|
]
|
|
325
345
|
},
|
|
@@ -359,7 +379,7 @@
|
|
|
359
379
|
"hashes": [
|
|
360
380
|
{
|
|
361
381
|
"alg": "SHA-256",
|
|
362
|
-
"content": "
|
|
382
|
+
"content": "acc30164bbafc2d46b404ff8ae87caf42e301e15dd005c424654ee0225af6efd"
|
|
363
383
|
}
|
|
364
384
|
]
|
|
365
385
|
},
|
|
@@ -399,7 +419,7 @@
|
|
|
399
419
|
"hashes": [
|
|
400
420
|
{
|
|
401
421
|
"alg": "SHA-256",
|
|
402
|
-
"content": "
|
|
422
|
+
"content": "dcaede3dea29d98d93f3c902b8f9f389f24dc7b9a84665d1c206263915282f06"
|
|
403
423
|
}
|
|
404
424
|
]
|
|
405
425
|
},
|
|
@@ -409,7 +429,7 @@
|
|
|
409
429
|
"hashes": [
|
|
410
430
|
{
|
|
411
431
|
"alg": "SHA-256",
|
|
412
|
-
"content": "
|
|
432
|
+
"content": "e99200ccdd8368260af18532478addc08e3b6195986cecf68b669e9dc639d119"
|
|
413
433
|
}
|
|
414
434
|
]
|
|
415
435
|
}
|
package/src/cli.js
CHANGED
|
@@ -38,7 +38,8 @@ Commands:
|
|
|
38
38
|
Authorize one exact VM plan for ten minutes within a RUB/month cap.
|
|
39
39
|
Static sites require the artifact digest shown by deploy_plan.
|
|
40
40
|
k8s-approve --cluster-id <uuid> --namespace <name> --manifest <file> --manifest-sha256 <digest>
|
|
41
|
-
|
|
41
|
+
[--profile snake|web-image]
|
|
42
|
+
Authorize one exact bounded manifest on an existing cluster.
|
|
42
43
|
cost-compare --input <json> [--json]
|
|
43
44
|
Offline VM vs Kubernetes cost worksheet; no engine or credentials.
|
|
44
45
|
install --target <t> claude-code: permissions + executed hook probe;
|
|
@@ -190,7 +191,7 @@ function main(argv, write = (s) => process.stdout.write(s + '\n')) {
|
|
|
190
191
|
case 'k8s-approve': {
|
|
191
192
|
try {
|
|
192
193
|
const approval = issueK8sApproval(opts.get('cluster-id'), opts.get('namespace'),
|
|
193
|
-
opts.get('manifest'), opts.get('manifest-sha256'));
|
|
194
|
+
opts.get('manifest'), opts.get('manifest-sha256'), process.env, Date.now(), opts.get('profile') || 'snake');
|
|
194
195
|
write(flags.has('json') ? JSON.stringify(approval, null, 2) :
|
|
195
196
|
`approved ${approval.approvalId}: cluster ${approval.clusterId}, namespace ${approval.namespace}, SHA-256 ${approval.manifestSha256}; expires ${new Date(approval.expiresAt).toISOString()}`);
|
|
196
197
|
return 0;
|
package/src/install.js
CHANGED
|
@@ -45,7 +45,7 @@ const TIERS = {
|
|
|
45
45
|
const DEGRADED_REASONS = {
|
|
46
46
|
openclaude: 'all three legs exist upstream (.mcp.json, PreToolUse deny, .openclaude/skills) but NOTHING has been probed live — degraded-until-proven (ADR-005).',
|
|
47
47
|
cursor: 'veto hooks documented only; R3 broken by the .mdc adapter, R4 truncation history (~40 tools) unresolved (ADR-005).',
|
|
48
|
-
windsurf: 'R4 FAILS as-is: Cascade budget 100 tools <
|
|
48
|
+
windsurf: 'R4 FAILS as-is: Cascade budget 100 tools < 146 — needs the engine tool-profile before any delivery (ADR-005).',
|
|
49
49
|
copilot: 'only Copilot CLI is a candidate; the coding agent fails R2 by construction ("will not ask for your approval" + hook timeout fail-open) (ADR-005).',
|
|
50
50
|
};
|
|
51
51
|
|
package/src/k8s-approval.js
CHANGED
|
@@ -10,6 +10,7 @@ const { isDeepStrictEqual } = require('util');
|
|
|
10
10
|
const YAML = require('yaml');
|
|
11
11
|
const { readCredentials, UUID } = require('./credentials');
|
|
12
12
|
const { approvalRoot } = require('./deploy-approval');
|
|
13
|
+
const { validateImageManifest } = require('./k8s-image-manifest');
|
|
13
14
|
|
|
14
15
|
const APPROVAL_ID = /^k8s-[0-9a-f]{12}$/;
|
|
15
16
|
const DNS_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
@@ -49,7 +50,9 @@ function privateJson(file) {
|
|
|
49
50
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
function validateManifest(raw, namespace) {
|
|
53
|
+
function validateManifest(raw, namespace, profile = 'snake') {
|
|
54
|
+
if (profile === 'web-image') return validateImageManifest(raw, namespace);
|
|
55
|
+
if (profile !== 'snake') throw new Error('unsupported Kubernetes profile');
|
|
53
56
|
if (typeof raw !== 'string' || Buffer.byteLength(raw) < 1 || Buffer.byteLength(raw) > MAX_MANIFEST) {
|
|
54
57
|
throw new Error('manifest must be non-empty and at most 64 KiB');
|
|
55
58
|
}
|
|
@@ -143,7 +146,7 @@ function approvalFile(id, env = process.env) {
|
|
|
143
146
|
return path.join(approvalRoot(env), `${id}.json`);
|
|
144
147
|
}
|
|
145
148
|
|
|
146
|
-
function issueK8sApproval(clusterId, namespace, manifestFile, reviewedSha256, env = process.env, now = Date.now()) {
|
|
149
|
+
function issueK8sApproval(clusterId, namespace, manifestFile, reviewedSha256, env = process.env, now = Date.now(), profile = 'snake') {
|
|
147
150
|
if (!UUID.test(clusterId || '')) throw new Error('cluster_id must be a UUID');
|
|
148
151
|
if (!DIGEST.test(reviewedSha256 || '')) throw new Error('reviewed manifest SHA-256 is required');
|
|
149
152
|
const st = fs.lstatSync(manifestFile);
|
|
@@ -151,17 +154,17 @@ function issueK8sApproval(clusterId, namespace, manifestFile, reviewedSha256, en
|
|
|
151
154
|
throw new Error('manifest source must be a bounded regular file');
|
|
152
155
|
}
|
|
153
156
|
const manifest = fs.readFileSync(manifestFile, 'utf8');
|
|
154
|
-
const details = validateManifest(manifest, namespace);
|
|
157
|
+
const details = validateManifest(manifest, namespace, profile);
|
|
155
158
|
const digest = sha256(manifest);
|
|
156
159
|
if (digest !== reviewedSha256) throw new Error('manifest digest differs from reviewed SHA-256');
|
|
157
160
|
const credentials = readCredentials(env);
|
|
158
161
|
const root = approvalRoot(env);
|
|
159
162
|
privateDir(root);
|
|
160
163
|
const id = `k8s-${crypto.randomBytes(6).toString('hex')}`;
|
|
161
|
-
const record = { id, projectId: credentials.projectId, clusterId, namespace, manifest, digest,
|
|
164
|
+
const record = { id, projectId: credentials.projectId, clusterId, namespace, manifest, digest, profile,
|
|
162
165
|
app: details.app, issuedAt: now, expiresAt: now + 10 * 60_000 };
|
|
163
166
|
fs.writeFileSync(approvalFile(id, env), JSON.stringify(record) + '\n', { flag: 'wx', mode: 0o600 });
|
|
164
|
-
return { approvalId: id, projectId: record.projectId, clusterId, namespace, manifestSha256: digest,
|
|
167
|
+
return { approvalId: id, projectId: record.projectId, clusterId, namespace, manifestSha256: digest, profile,
|
|
165
168
|
resources: details.resources, image: details.image, expiresAt: record.expiresAt,
|
|
166
169
|
estimateScope: 'existing cluster only; no cluster, worker, NAT or public load balancer is created' };
|
|
167
170
|
}
|
|
@@ -172,7 +175,7 @@ function consumeK8sApproval(id, env = process.env, now = Date.now()) {
|
|
|
172
175
|
const credentials = readCredentials(env);
|
|
173
176
|
if (a.id !== id || a.projectId !== credentials.projectId || !UUID.test(a.clusterId || '') ||
|
|
174
177
|
!Number.isFinite(a.expiresAt) || a.expiresAt <= now || !DIGEST.test(a.digest || '') ||
|
|
175
|
-
sha256(a.manifest) !== a.digest || validateManifest(a.manifest, a.namespace).app !== a.app) {
|
|
178
|
+
sha256(a.manifest) !== a.digest || validateManifest(a.manifest, a.namespace, a.profile).app !== a.app) {
|
|
176
179
|
throw new Error('Kubernetes approval does not match manifest, project or time');
|
|
177
180
|
}
|
|
178
181
|
const spent = path.join(approvalRoot(env), `${id}.used-${crypto.randomBytes(8).toString('hex')}.json`);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// A narrow generic web workload: one digest-pinned image, one Deployment and
|
|
4
|
+
// one internal Service. The reviewed digest is the security boundary; source
|
|
5
|
+
// build and image registry publication are separate operator steps.
|
|
6
|
+
const YAML = require('yaml');
|
|
7
|
+
const { isDeepStrictEqual } = require('util');
|
|
8
|
+
const DNS = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
9
|
+
const SERVICE_NAME = /^[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
10
|
+
const IMAGE = /^[a-z0-9][a-z0-9._:/-]*@sha256:[a-f0-9]{64}$/;
|
|
11
|
+
const isRecord = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
12
|
+
const fields = (obj, allowed, label) => {
|
|
13
|
+
if (!isRecord(obj) || Object.keys(obj).some((key) => !allowed.includes(key))) throw new Error(`${label} contains unsupported fields`);
|
|
14
|
+
};
|
|
15
|
+
const equal = (actual, expected, label) => {
|
|
16
|
+
if (!isDeepStrictEqual(actual, expected)) throw new Error(`${label} differs from the reviewed web-image profile`);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function validateImageManifest(raw, namespace) {
|
|
20
|
+
if (typeof raw !== 'string' || Buffer.byteLength(raw) < 1 || Buffer.byteLength(raw) > 64 * 1024) throw new Error('manifest must be 1..65536 bytes');
|
|
21
|
+
if (typeof namespace !== 'string' || !DNS.test(namespace) || namespace.length > 63 || namespace === 'default' || namespace === 'kube-system') {
|
|
22
|
+
throw new Error('a dedicated DNS namespace is required');
|
|
23
|
+
}
|
|
24
|
+
const docs = YAML.parseAllDocuments(raw, { uniqueKeys: true, maxAliasCount: 0 });
|
|
25
|
+
if (docs.length !== 2 || docs.some((doc) => doc.errors.length)) throw new Error('web-image manifest needs exactly Deployment and Service');
|
|
26
|
+
const resources = docs.map((doc) => doc.toJS({ maxAliasCount: 0 }));
|
|
27
|
+
const deployment = resources.find((r) => r?.kind === 'Deployment');
|
|
28
|
+
const service = resources.find((r) => r?.kind === 'Service');
|
|
29
|
+
if (!deployment || !service || deployment === service) throw new Error('web-image manifest needs Deployment and Service');
|
|
30
|
+
for (const [r, kind, api] of [[deployment, 'Deployment', 'apps/v1'], [service, 'Service', 'v1']]) {
|
|
31
|
+
fields(r, ['apiVersion', 'kind', 'metadata', 'spec'], kind);
|
|
32
|
+
if (r.apiVersion !== api || r.kind !== kind) throw new Error(`invalid ${kind} API`);
|
|
33
|
+
fields(r.metadata, ['name', 'namespace'], `${kind} metadata`);
|
|
34
|
+
if (!SERVICE_NAME.test(r.metadata.name || '') || r.metadata.namespace !== namespace) throw new Error(`${kind} must use the reviewed namespace and a Service-compatible name`);
|
|
35
|
+
}
|
|
36
|
+
const app = deployment.metadata.name;
|
|
37
|
+
if (service.metadata.name !== app) throw new Error('Service name must match Deployment');
|
|
38
|
+
const spec = deployment.spec;
|
|
39
|
+
fields(spec, ['replicas', 'selector', 'template'], 'Deployment spec');
|
|
40
|
+
if (spec.replicas !== 1) throw new Error('only one replica is allowed');
|
|
41
|
+
fields(spec.selector, ['matchLabels'], 'selector');
|
|
42
|
+
equal(spec.selector.matchLabels, { app }, 'selector');
|
|
43
|
+
fields(spec.template, ['metadata', 'spec'], 'pod template');
|
|
44
|
+
fields(spec.template.metadata, ['labels'], 'pod metadata');
|
|
45
|
+
equal(spec.template.metadata.labels, { app }, 'pod labels');
|
|
46
|
+
const pod = spec.template.spec;
|
|
47
|
+
fields(pod, ['automountServiceAccountToken', 'securityContext', 'containers'], 'pod spec');
|
|
48
|
+
if (pod.automountServiceAccountToken !== false) throw new Error('service account token must be disabled');
|
|
49
|
+
equal(pod.securityContext, { runAsNonRoot: true }, 'pod security context');
|
|
50
|
+
if (!Array.isArray(pod.containers) || pod.containers.length !== 1) throw new Error('exactly one container is required');
|
|
51
|
+
const container = pod.containers[0];
|
|
52
|
+
fields(container, ['name', 'image', 'ports', 'readinessProbe', 'resources', 'securityContext'], 'container');
|
|
53
|
+
if (container.name !== app || !IMAGE.test(container.image || '') || container.image.includes('://') || container.image.includes('//')) throw new Error('container needs a digest-pinned OCI image');
|
|
54
|
+
equal(container.ports, [{ containerPort: 8080 }], 'container ports');
|
|
55
|
+
equal(container.readinessProbe, { httpGet: { path: '/health', port: 8080 } }, 'readiness probe');
|
|
56
|
+
equal(container.securityContext, { allowPrivilegeEscalation: false, readOnlyRootFilesystem: true,
|
|
57
|
+
runAsUser: 10001, runAsGroup: 10001, capabilities: { drop: ['ALL'] } }, 'container security context');
|
|
58
|
+
const requests = container.resources?.requests;
|
|
59
|
+
const limits = container.resources?.limits;
|
|
60
|
+
fields(container.resources, ['requests', 'limits'], 'resources');
|
|
61
|
+
fields(requests, ['cpu', 'memory'], 'requests');
|
|
62
|
+
fields(limits, ['cpu', 'memory'], 'limits');
|
|
63
|
+
const cpu = (v, max) => typeof v === 'string' && /^[1-9][0-9]{0,3}m$/.test(v) && Number.parseInt(v, 10) <= max;
|
|
64
|
+
const mem = (v, max) => typeof v === 'string' && /^[1-9][0-9]{0,3}Mi$/.test(v) && Number.parseInt(v, 10) <= max;
|
|
65
|
+
if (!cpu(requests.cpu, 500) || !cpu(limits.cpu, 1000) || !mem(requests.memory, 512) || !mem(limits.memory, 1024) ||
|
|
66
|
+
Number.parseInt(requests.cpu, 10) > Number.parseInt(limits.cpu, 10) ||
|
|
67
|
+
Number.parseInt(requests.memory, 10) > Number.parseInt(limits.memory, 10)) throw new Error('resources exceed bounded web-image limits');
|
|
68
|
+
fields(service.spec, ['type', 'selector', 'ports'], 'Service spec');
|
|
69
|
+
if (service.spec.type !== 'ClusterIP') throw new Error('Service must be internal ClusterIP');
|
|
70
|
+
equal(service.spec.selector, { app }, 'Service selector');
|
|
71
|
+
equal(service.spec.ports, [{ port: 80, targetPort: 8080 }], 'Service ports');
|
|
72
|
+
return { app, image: container.image, resources: [{ kind: 'Deployment', name: app }, { kind: 'Service', name: app }] };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { validateImageManifest };
|
package/src/safe-mcp.js
CHANGED
|
@@ -6,13 +6,18 @@ const { spawn } = require('child_process');
|
|
|
6
6
|
const { loadClassification } = require('./classification');
|
|
7
7
|
const { resolveEngine } = require('./resolve');
|
|
8
8
|
const { selectedToolNames } = require('./toolsets');
|
|
9
|
-
const { readCredentials, readOptionalCredentials } = require('./credentials');
|
|
9
|
+
const { readCredentials, readOptionalCredentials, UUID } = require('./credentials');
|
|
10
10
|
const { consumeApproval, recordDeployedVM, approvedVM } = require('./deploy-approval');
|
|
11
11
|
const { consumeK8sApproval, k8sSession, verificationArgs } = require('./k8s-approval');
|
|
12
12
|
|
|
13
13
|
const MAX_LINE = 1024 * 1024;
|
|
14
14
|
const idKey = (id) => JSON.stringify(id);
|
|
15
15
|
const errorResponse = (id, message, code = -32000) => ({ jsonrpc: '2.0', id, error: { code, message } });
|
|
16
|
+
const uncertainDeploy = (id, approval, reason) => ({ jsonrpc: '2.0', id,
|
|
17
|
+
result: { isError: true, content: [{ type: 'text', text: JSON.stringify({
|
|
18
|
+
status: 'recovery_required', plan_id: approval.planId, verdict: 'unknown',
|
|
19
|
+
reason, next: 'Call deploy_reconcile with this plan_id; inspect live inventory before any new plan or mutation.',
|
|
20
|
+
}) }] } });
|
|
16
21
|
|
|
17
22
|
function redactDeploySecrets(value) {
|
|
18
23
|
if (Array.isArray(value)) return value.map(redactDeploySecrets);
|
|
@@ -81,8 +86,10 @@ function runSafeMcp(options = {}) {
|
|
|
81
86
|
if (stopped) return;
|
|
82
87
|
stopped = true;
|
|
83
88
|
stderr.write(`cloudru-hub mcp-safe: ${reason}\n`);
|
|
84
|
-
for (const key of pending
|
|
85
|
-
|
|
89
|
+
for (const [key, request] of pending) {
|
|
90
|
+
const id = JSON.parse(key);
|
|
91
|
+
emit(request.approval ? uncertainDeploy(id, request.approval, 'Cloud.ru engine connection closed during deploy_apply') :
|
|
92
|
+
errorResponse(id, 'Cloud.ru engine connection closed'));
|
|
86
93
|
}
|
|
87
94
|
pending.clear();
|
|
88
95
|
stdin.destroy();
|
|
@@ -210,10 +217,10 @@ function runSafeMcp(options = {}) {
|
|
|
210
217
|
tool && typeof tool.name === 'string' && selected.has(tool.name)).map((tool) => {
|
|
211
218
|
if (!k8sProfile) return tool;
|
|
212
219
|
if (tool.name === 'k8s_apply') return { ...tool,
|
|
213
|
-
description: 'Apply one locally reviewed Snake manifest to one existing cluster. Pass only the local approval ID.',
|
|
220
|
+
description: 'Apply one locally reviewed bounded Snake or web-image manifest to one existing cluster. Pass only the local approval ID.',
|
|
214
221
|
inputSchema: { type: 'object', properties: { approval_id: { type: 'string' } }, required: ['approval_id'], additionalProperties: false } };
|
|
215
222
|
if (tool.name === 'k8s_kubectl') return { ...tool,
|
|
216
|
-
description: 'Read only pods, deployment, service or rollout status for the previously approved
|
|
223
|
+
description: 'Read only pods, deployment, service or rollout status for the previously approved workload.',
|
|
217
224
|
inputSchema: { type: 'object', properties: { approval_id: { type: 'string' },
|
|
218
225
|
query: { type: 'string', enum: ['pods', 'deployment', 'service', 'rollout'] } },
|
|
219
226
|
required: ['approval_id', 'query'], additionalProperties: false } };
|
|
@@ -226,7 +233,11 @@ function runSafeMcp(options = {}) {
|
|
|
226
233
|
if (method === 'initialize' && message.result && message.result.capabilities) {
|
|
227
234
|
message.result = { ...message.result, capabilities: { tools: message.result.capabilities.tools || {} } };
|
|
228
235
|
}
|
|
229
|
-
if (approval &&
|
|
236
|
+
if (approval && message.error) {
|
|
237
|
+
emit(uncertainDeploy(message.id, approval, 'Cloud.ru engine returned an error after approval was consumed'));
|
|
238
|
+
closeIfDone(); return;
|
|
239
|
+
}
|
|
240
|
+
if (approval) {
|
|
230
241
|
// The engine returns an MCP text result containing its JSON deploy result.
|
|
231
242
|
// Keep the model from seeing a generated login password: vm_exec uses the
|
|
232
243
|
// engine-managed SSH key. Ambiguous responses require inspection.
|
|
@@ -234,28 +245,32 @@ function runSafeMcp(options = {}) {
|
|
|
234
245
|
let deploy;
|
|
235
246
|
try { deploy = JSON.parse(item?.text || ''); }
|
|
236
247
|
catch {
|
|
237
|
-
message.
|
|
238
|
-
|
|
248
|
+
emit(uncertainDeploy(message.id, approval, 'deploy_apply returned an unreadable outcome'));
|
|
249
|
+
closeIfDone(); return;
|
|
239
250
|
}
|
|
240
251
|
if (!deploy || typeof deploy !== 'object' || Array.isArray(deploy)) {
|
|
241
|
-
message.
|
|
242
|
-
|
|
252
|
+
emit(uncertainDeploy(message.id, approval, 'deploy_apply returned an invalid outcome'));
|
|
253
|
+
closeIfDone(); return;
|
|
243
254
|
}
|
|
244
255
|
const safe = redactDeploySecrets(deploy);
|
|
245
|
-
|
|
256
|
+
safe.operation = { plan_id: approval.planId, verdict: 'unknown', observed_at: new Date().toISOString() };
|
|
257
|
+
if (!message.result?.isError && approval.siteMode && deploy.status === 'running' && deploy.http_verification?.verified === true &&
|
|
258
|
+
UUID.test(deploy.vm_id || '')) {
|
|
246
259
|
// Static-site delivery is verified over HTTP. It does not imply SSH is
|
|
247
260
|
// available, so never create a vm_exec session for this VM.
|
|
261
|
+
safe.operation.verdict = 'http_verified';
|
|
248
262
|
} else if (!message.result?.isError && !approval.siteMode && deploy.status === 'running') {
|
|
249
263
|
try {
|
|
250
264
|
const session = recordDeployedVM(approval, deploy, options.env || process.env);
|
|
251
265
|
safe.approved_vm_session_expires_at = new Date(session.expiresAt).toISOString();
|
|
266
|
+
safe.operation.verdict = 'vm_running';
|
|
252
267
|
} catch (error) {
|
|
253
268
|
message.result.isError = true;
|
|
254
269
|
safe.recovery = `VM may exist but the approved execution session was not recorded: ${error.message}. Inspect VM inventory before retrying.`;
|
|
255
270
|
}
|
|
256
271
|
} else {
|
|
257
272
|
message.result.isError = true;
|
|
258
|
-
safe.recovery = 'A VM may exist.
|
|
273
|
+
safe.recovery = 'A VM may exist. Call deploy_reconcile with the plan_id. Inspect live inventory before retrying; a fresh plan and approval may be needed.';
|
|
259
274
|
}
|
|
260
275
|
item.text = JSON.stringify(safe);
|
|
261
276
|
}
|
package/src/toolsets.js
CHANGED
|
@@ -15,7 +15,7 @@ const GROUPS = Object.freeze({
|
|
|
15
15
|
// A separate, opt-in workflow. Mutators pass the proxy's local plan/VM guard;
|
|
16
16
|
// combining this with a read-only group would blur that boundary.
|
|
17
17
|
const VM_DEPLOY = Object.freeze([
|
|
18
|
-
'deploy_plan', 'deploy_apply', 'vm_exec', 'vm_list', 'vm_get',
|
|
18
|
+
'deploy_plan', 'deploy_preflight', 'deploy_apply', 'deploy_reconcile', 'vm_exec', 'vm_list', 'vm_get',
|
|
19
19
|
'list_zones', 'list_images', 'list_flavors', 'list_disk_types',
|
|
20
20
|
'list_subnets', 'list_security_groups', 'status',
|
|
21
21
|
]);
|
|
File without changes
|
|
@@ -16,15 +16,15 @@ test('every snapshot tool is classified, and nothing extra', () => {
|
|
|
16
16
|
const snapNames = snap.tools.map((t) => t.name).sort();
|
|
17
17
|
const clsNames = Object.keys(cls.tools).sort();
|
|
18
18
|
assert.deepEqual(clsNames, snapNames);
|
|
19
|
-
assert.equal(snapNames.length,
|
|
19
|
+
assert.equal(snapNames.length, 146);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
test('counts are the reviewed golden split: 64 ask / 2 deny /
|
|
23
|
-
assert.deepEqual(cls.counts, { ask: 64, deny: 2, allow:
|
|
22
|
+
test('counts are the reviewed golden split: 64 ask / 2 deny / 80 allow', () => {
|
|
23
|
+
assert.deepEqual(cls.counts, { ask: 64, deny: 2, allow: 80 });
|
|
24
24
|
const by = namesByPermission(cls);
|
|
25
25
|
assert.equal(by.ask.length, 64);
|
|
26
26
|
assert.equal(by.deny.length, 2);
|
|
27
|
-
assert.equal(by.allow.length,
|
|
27
|
+
assert.equal(by.allow.length, 80);
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
// The imported engine now makes status observational. The Go regression test
|
|
@@ -121,7 +121,7 @@ test('permissionRules expands every name in BOTH prefix spellings (unverified no
|
|
|
121
121
|
assert.equal(SERVER_PREFIXES.length, 2);
|
|
122
122
|
assert.equal(rules.ask.length, 64 * 2);
|
|
123
123
|
assert.equal(rules.deny.length, 2 * 2);
|
|
124
|
-
assert.equal(rules.allow.length,
|
|
124
|
+
assert.equal(rules.allow.length, 80 * 2);
|
|
125
125
|
assert.ok(rules.ask.includes('mcp__cloudru-vm__deploy'));
|
|
126
126
|
assert.ok(rules.ask.includes('mcp__cloudru_vm__deploy'));
|
|
127
127
|
assert.ok(rules.deny.includes('mcp__cloudru-vm__secret_value'));
|
|
@@ -110,6 +110,18 @@ test('partial deploy preserves its VM UUID for recovery and redacts nested secre
|
|
|
110
110
|
assert.ok(denied.responses[0].error);
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
test('unreadable apply result names the consumed plan and requires read-only reconciliation', () => {
|
|
114
|
+
const id = 'plan-555555555555';
|
|
115
|
+
fs.writeFileSync(path.join(path.dirname(planFile), `${id}.json`), JSON.stringify({ ...plan, plan_id: id }));
|
|
116
|
+
issueApproval(id, 50, env);
|
|
117
|
+
const out = run([rpc(1, 'deploy_apply', { plan_id: id })], { FAKE_DEPLOY_OUTCOME: JSON.stringify('unreadable') });
|
|
118
|
+
const result = JSON.parse(out.responses[0].result.content[0].text);
|
|
119
|
+
assert.equal(out.responses[0].result.isError, true);
|
|
120
|
+
assert.equal(result.plan_id, id);
|
|
121
|
+
assert.equal(result.verdict, 'unknown');
|
|
122
|
+
assert.match(result.next, /deploy_reconcile/);
|
|
123
|
+
});
|
|
124
|
+
|
|
113
125
|
test('static site requires the reviewed digest and never grants an SSH command session', () => {
|
|
114
126
|
const id = 'plan-aabbccddeeff';
|
|
115
127
|
const digest = 'a'.repeat(64);
|
|
@@ -67,6 +67,46 @@ test('unexpected mutators, exposure, privilege, image and namespace are rejected
|
|
|
67
67
|
}
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
test('web-image profile accepts a reviewed digest-pinned application and refuses privilege or exposure drift', () => {
|
|
71
|
+
const app = 'lesson-web';
|
|
72
|
+
const image = `registry.example.com/lesson-web@sha256:${'a'.repeat(64)}`;
|
|
73
|
+
const web = [
|
|
74
|
+
{ apiVersion: 'apps/v1', kind: 'Deployment', metadata: { name: app, namespace: 'lesson-app' }, spec: {
|
|
75
|
+
replicas: 1, selector: { matchLabels: { app } }, template: { metadata: { labels: { app } }, spec: {
|
|
76
|
+
automountServiceAccountToken: false, securityContext: { runAsNonRoot: true },
|
|
77
|
+
containers: [{ name: app, image, ports: [{ containerPort: 8080 }], readinessProbe: { httpGet: { path: '/health', port: 8080 } },
|
|
78
|
+
resources: { requests: { cpu: '50m', memory: '64Mi' }, limits: { cpu: '200m', memory: '256Mi' } },
|
|
79
|
+
securityContext: { allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, runAsUser: 10001,
|
|
80
|
+
runAsGroup: 10001, capabilities: { drop: ['ALL'] } } }],
|
|
81
|
+
} } } },
|
|
82
|
+
{ apiVersion: 'v1', kind: 'Service', metadata: { name: app, namespace: 'lesson-app' },
|
|
83
|
+
spec: { type: 'ClusterIP', selector: { app }, ports: [{ port: 80, targetPort: 8080 }] } },
|
|
84
|
+
];
|
|
85
|
+
const raw = serialize(web);
|
|
86
|
+
assert.equal(validateManifest(raw, 'lesson-app', 'web-image').image, image);
|
|
87
|
+
assert.throws(() => validateManifest(raw, 'lesson-app'), /three valid YAML documents/);
|
|
88
|
+
for (const mutate of [
|
|
89
|
+
(r) => { r[0].spec.template.spec.containers[0].image = 'registry.example.com/lesson-web:latest'; },
|
|
90
|
+
(r) => { r[0].spec.template.spec.containers[0].image = `https://registry.example.com/lesson-web@sha256:${'a'.repeat(64)}`; },
|
|
91
|
+
(r) => { r[0].spec.template.spec.hostNetwork = true; },
|
|
92
|
+
(r) => { r[0].spec.template.spec.containers[0].securityContext.privileged = true; },
|
|
93
|
+
(r) => { r[1].spec.type = 'LoadBalancer'; },
|
|
94
|
+
(r) => { r[0].metadata.namespace = 'default'; },
|
|
95
|
+
(r) => { r[0].metadata.name = r[1].metadata.name = '123'; r[0].spec.selector.matchLabels.app = r[0].spec.template.metadata.labels.app = r[1].spec.selector.app = '123'; r[0].spec.template.spec.containers[0].name = '123'; },
|
|
96
|
+
]) {
|
|
97
|
+
const changed = structuredClone(web); mutate(changed);
|
|
98
|
+
assert.throws(() => validateManifest(serialize(changed), 'lesson-app', 'web-image'));
|
|
99
|
+
}
|
|
100
|
+
const source = path.join(scratch, 'lesson-app.yaml');
|
|
101
|
+
fs.writeFileSync(source, raw);
|
|
102
|
+
const reviewed = require('crypto').createHash('sha256').update(raw).digest('hex');
|
|
103
|
+
const approval = issueK8sApproval(clusterId, 'lesson-app', source, reviewed, env, Date.now(), 'web-image');
|
|
104
|
+
assert.equal(approval.profile, 'web-image');
|
|
105
|
+
assert.equal(consumeK8sApproval(approval.approvalId, env).manifest, raw);
|
|
106
|
+
fs.writeFileSync(source, serialize([{ ...web[0], metadata: { name: 'changed', namespace: 'lesson-app' } }, web[1]]));
|
|
107
|
+
assert.throws(() => issueK8sApproval(clusterId, 'lesson-app', source, reviewed, env, Date.now(), 'web-image'), /digest differs|must match/);
|
|
108
|
+
});
|
|
109
|
+
|
|
70
110
|
test('digest, project, expiry and single-use approval bind the exact manifest', () => {
|
|
71
111
|
assert.throws(() => issueK8sApproval(clusterId, 'snake-pilot', manifestFile, '0'.repeat(64), env), /digest differs/);
|
|
72
112
|
const a = issueK8sApproval(clusterId, 'snake-pilot', manifestFile, digest, env, 1000);
|
package/test/resolve.test.mjs
CHANGED
|
@@ -125,6 +125,6 @@ test('sha256File matches node crypto', () => {
|
|
|
125
125
|
test('the pinned linux-x64 hash is the rebuilt private engine sha256', () => {
|
|
126
126
|
assert.equal(
|
|
127
127
|
PKG.cloudruHub.binaryHashes['linux-x64'],
|
|
128
|
-
'
|
|
128
|
+
'0095d3911da7e3a93f1d2ee064708820b9b9c50bfc5cbd99fc46c7da6db8be08',
|
|
129
129
|
);
|
|
130
130
|
});
|
package/test/safe-mcp.test.mjs
CHANGED
|
@@ -47,6 +47,26 @@ test('safe proxy filters listed tools and blocks ask, deny, unknown and other me
|
|
|
47
47
|
assert.deepEqual(out.forwarded.filter((m) => m.method === 'tools/call').map((m) => m.params.name), ['vm_list']);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
test('k8s_get recovery selectors pass existing read profiles while paid creates stay denied', () => {
|
|
51
|
+
const snapshot = JSON.parse(fs.readFileSync(path.join(pkg, 'data/tools-list-snapshot.json'), 'utf8'));
|
|
52
|
+
assert.equal(snapshot.toolCount, 146);
|
|
53
|
+
assert.equal(snapshot.tools.length, 146);
|
|
54
|
+
for (const profile of ['all', 'kubernetes', 'k8s-deploy']) {
|
|
55
|
+
const out = run([
|
|
56
|
+
msg(1, 'tools/call', { name: 'k8s_get', arguments: { receipts: true, limit: 10 } }),
|
|
57
|
+
msg(2, 'tools/call', { name: 'k8s_get', arguments: { receipt_id: 'a'.repeat(64) } }),
|
|
58
|
+
msg(3, 'tools/call', { name: 'k8s_create', arguments: { confirmed: true } }),
|
|
59
|
+
msg(4, 'tools/call', { name: 'k8s_add_nodepool', arguments: { confirmed: true } }),
|
|
60
|
+
], profile);
|
|
61
|
+
const byId = Object.fromEntries(out.responses.map((r) => [r.id, r]));
|
|
62
|
+
assert.match(byId[1].result.content[0].text, /executed k8s_get/);
|
|
63
|
+
assert.match(byId[2].result.content[0].text, /executed k8s_get/);
|
|
64
|
+
assert.ok(byId[3].error, `${profile}: k8s_create must be denied`);
|
|
65
|
+
assert.ok(byId[4].error, `${profile}: k8s_add_nodepool must be denied`);
|
|
66
|
+
assert.deepEqual(out.forwarded.map((m) => m.params.name), ['k8s_get', 'k8s_get']);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
50
70
|
test('Hermes keepalive gets the filtered tool list while the serial engine is busy', async () => {
|
|
51
71
|
const serial = path.join(scratch, 'serial-engine');
|
|
52
72
|
const serialLog = path.join(scratch, 'serial-forwarded.jsonl');
|