@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 CHANGED
@@ -5,7 +5,7 @@
5
5
  "files": [
6
6
  {
7
7
  "path": "CHANGELOG.md",
8
- "sha256": "2b0dbd83deb813844a658814b9a6fa1640f1e5255c676210e61dceaa6b42195b"
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": "4f6a2d5928fec42f021eb10471677b902e831b99d53096661a821f6460bd55a6"
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": "6f4557d97b90936ef153dc90eac4bdceb6846b6d08d8aec45c86baac328cca60"
32
+ "sha256": "32845b9cc1ad64f2f6a8d4eeb8b90bf36710590d25db4234e6813793ef63c5d4"
33
33
  },
34
34
  {
35
35
  "path": "data/tools-classification.json",
36
- "sha256": "9abb3ca6347b0cd6cd51b23649c59e0a0c816e6c12a325f573b392affa350d0b"
36
+ "sha256": "0f09cb2fffd2feebfa5e7e97e8503a47ae1b63d1035e309540a4211f0bace477"
37
37
  },
38
38
  {
39
39
  "path": "data/tools-list-snapshot.json",
40
- "sha256": "dff98bb859dfbbf8ed2fb6f16f17b767444cc2172ecc7684ca3df8188c95707c"
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": "3605797972f9a7c79c13fbcce0cd9ac8b8fb3a8249abbab938f2b1a45f515420"
48
+ "sha256": "00a5c9acc3999c5e07f230ba63bc1a0e202d62c8d15d2204620feca762eece8f"
49
49
  },
50
50
  {
51
51
  "path": "docs/LOCAL-TESTING.md",
52
- "sha256": "de6f709e820fbb831f390e274238b28ba938644670a8db04fd50d1fa0d86da75"
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": "1b355df670fb092da7ce8956141e702f9f78e1e2f2724c6aaba61bbab2acd8c8"
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": "b1bd4a0ce7f1ca869c2873ca1979a74630483ee2909ff8b4f91189622f5aa2a7"
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": "c928db48febfb4a79884ce1830365a8302122a55fb5d6ada8e771c049396ca89"
92
+ "sha256": "90af414aabda1c48c82c9c2ecf0dbe4d7dd4744486efdce0d8367182b6a67a38"
89
93
  },
90
94
  {
91
95
  "path": "src/k8s-approval.js",
92
- "sha256": "014c2effd1e843d870ae1af4f93f5c82faff9972e9f6ca028ed39d47ec33ee3a"
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": "4f95cfb9576c411232503b26053ece6d8d186e4b337beff9bd4afd249f74a014"
112
+ "sha256": "60a5db44db9518835f98f23aa104b86c3621f0b0c030196c2130715f5c19c219"
105
113
  },
106
114
  {
107
115
  "path": "src/toolsets.js",
108
- "sha256": "89229c882b6a3f5bb9ee542b31d1a40374b91879747582a3e82e8aadc72decb3"
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": "026e3031180ce9227b4d31775b9c886b10ad0cb418ed0754aed75b12ded84e31"
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": "d72e4363fd50d0fcc65fd23b40601516bd73368c3659f2a55a394c4d1ed3f85c"
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": "af04d1941fb7aed2cc51b05f65ffed1c440d4d23a7fb26e13261d45e07b0a1f8"
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": "9ad83ba418dee86ef680575a0cc7da4af4be3ae995c00f23f84b336838fbda47"
168
+ "sha256": "dcaede3dea29d98d93f3c902b8f9f389f24dc7b9a84665d1c206263915282f06"
161
169
  },
162
170
  {
163
171
  "path": "test/safe-mcp.test.mjs",
164
- "sha256": "45406dabeee0635ede5cda3d2f4144c5c13886d624287fd223b01252d2859b87"
172
+ "sha256": "e99200ccdd8368260af18532478addc08e3b6195986cecf68b669e9dc639d119"
165
173
  }
166
174
  ]
167
175
  },
168
- "signature": "dwHxhW1FRdiGNeOrlmZDXpWHEV3i/V9QenSrenI7+SvF6HNfxc1cM++fUKzWXZKRvTfbreooCqZHfcipil4vAw=="
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
- ## Unreleased — staged, no version change
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. No engine or MCP tool changes are included.
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 distribution:** version 0.1.14 installs the exact optional engine
6
- > package `@dzhechkov/cloudru-vm-linux-x64@0.2.6`. The `dz guard` `licence-hold`
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 **144 tools**
14
- (MEASURED — live `tools/list` probe, `data/tools-list-snapshot.json`) for Cloud.ru
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. On Linux/x64, npm installs the exact
19
- optional platform package with the pinned Go engine. Run:
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.14
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, 144 live MCP tools and their
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 78 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 |
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 Snake profile
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 `4463b093…97a97c9b6`, engine version, 144 live tools, full
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 included in the launcher as **opt-in previews**. They require the matching pinned Go binary and temporary credentials in a private local file. On Linux/x64, a normal npm installation supplies that binary through the optional platform package. A static site can be planned from a local HTML file or a bounded ZIP snapshot with `deploy_plan(static_site_source=...)`; the operator checks the source digest shown in the plan and supplies that digest to `approve`. Every included ZIP file is served publicly, so review the file list in the plan and keep credentials out of the artifact. Planning requires an existing available floating IP, whose ID is bound to the approval. Apply creates a dedicated TCP-80 security group, boots the VM from fixed Cloud-init data, and reports the site as verified only after the approved IP is attached to that VM and exact-byte HTTP checks pass. A private run journal keeps resource IDs for interrupted-run recovery and blocks blind replay. The estimate covers compute and disk; a public IP may have a separate charge. The static-site route does not authorize `vm_exec` over SSH. See the two-Snake acceptance procedure in `features/cloudru-cli-vm-deploy/SNAKE-ACCEPTANCE.md` in the private source tree.
93
-
94
- The Kubernetes preview accepts only one reviewed manifest containing a ConfigMap, a non-root single-pod Deployment and a ClusterIP Service in an existing namespace. `k8s-approve` binds exact manifest bytes, cluster, namespace, project and a ten-minute single-use approval. The agent passes only the approval ID; the proxy forwards `create_only=true`, so an existing resource name is a conflict instead of being patched. Kubernetes `create` is not transactional across three documents: inspect and clean up any partially created resources by exact name before trying again. Use a dedicated namespace with no pre-existing resources matching the app label or the three approved names. The profile does not create a cluster, namespace, nodes, NAT, ingress or load balancer. The example, local evidence and four-host live acceptance are in `features/cloudru-k8s-snake-deploy/` in private source. The service account's initial 403 was resolved by granting the Kubernetes role; Claude Code inference remains unavailable for a live host test.
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.6` package is installed
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
File without changes
@@ -1,7 +1,7 @@
1
1
  # Cloud.ru Evolution — указатель (pointer-only таргет)
2
2
 
3
3
  Этот инструмент НЕ несёт на данном таргете ни MCP-тулов Cloud.ru, ни исполняемых
4
- инструкций деплоя. Полная связка (MCP-сервер `cloudru-vm` на 144 тула + скилл +
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": "4463b09323c9dfe0c32d618d335734a947e7fab0a75d07a7bea089b97a97c9b6",
4
- "toolCount": 144,
3
+ "engineSha256": "0095d3911da7e3a93f1d2ee064708820b9b9c50bfc5cbd99fc46c7da6db8be08",
4
+ "toolCount": 146,
5
5
  "counts": {
6
6
  "ask": 64,
7
7
  "deny": 2,
8
- "allow": 78
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-20",
3
- "engineVersion": "cloudru-vm 0.2.6-20261020",
4
- "engineSha256": "4463b09323c9dfe0c32d618d335734a947e7fab0a75d07a7bea089b97a97c9b6",
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": 144,
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
@@ -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 78 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.
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 78 allow-classified tools. The proxy checks the chosen set on every `tools/call`, so a tool hidden from discovery cannot be invoked directly.
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.6` 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.
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`, 12 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` |
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 |
@@ -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.6-20261020` and has SHA-256
23
- `4463b09323c9dfe0c32d618d335734a947e7fab0a75d07a7bea089b97a97c9b6`.
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
- Expected output for the current private build:
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: 4463b09323c9dfe0c32d618d335734a947e7fab0a75d07a7bea089b97a97c9b6 — MATCHES pinned baseline
37
- ✓ engine-version: cloudru-vm 0.2.6-20261020
38
- ✓ tools-list: 144 tools live (snapshot: 144)
39
- ✓ classification-coverage: every live tool is classified (144 golden entries)
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
- `tools/list` needs no Cloud.ru credentials. For `--toolsets vm-deploy`, place temporary keys in the owner-only JSON file described in `HERMES-CODEX.md`; the proxy maps them to the Go child's environment without shell evaluation.
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.14` normally and run `cloudru-hub self-test` without setting `CLOUDRU_VM_BIN`. npm installs the exact optional engine package. If optional dependencies were disabled or the platform is unsupported, provide a matching trusted binary explicitly.
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.15",
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": "4463b09323c9dfe0c32d618d335734a947e7fab0a75d07a7bea089b97a97c9b6"
29
+ "linux-x64": "0095d3911da7e3a93f1d2ee064708820b9b9c50bfc5cbd99fc46c7da6db8be08"
30
30
  },
31
- "engineVersion": "cloudru-vm 0.2.6-20261020",
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.6"
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": "2b0dbd83deb813844a658814b9a6fa1640f1e5255c676210e61dceaa6b42195b"
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": "4f6a2d5928fec42f021eb10471677b902e831b99d53096661a821f6460bd55a6"
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": "6f4557d97b90936ef153dc90eac4bdceb6846b6d08d8aec45c86baac328cca60"
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": "9abb3ca6347b0cd6cd51b23649c59e0a0c816e6c12a325f573b392affa350d0b"
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": "dff98bb859dfbbf8ed2fb6f16f17b767444cc2172ecc7684ca3df8188c95707c"
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": "3605797972f9a7c79c13fbcce0cd9ac8b8fb3a8249abbab938f2b1a45f515420"
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": "de6f709e820fbb831f390e274238b28ba938644670a8db04fd50d1fa0d86da75"
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": "1b355df670fb092da7ce8956141e702f9f78e1e2f2724c6aaba61bbab2acd8c8"
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": "b1bd4a0ce7f1ca869c2873ca1979a74630483ee2909ff8b4f91189622f5aa2a7"
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": "c928db48febfb4a79884ce1830365a8302122a55fb5d6ada8e771c049396ca89"
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": "014c2effd1e843d870ae1af4f93f5c82faff9972e9f6ca028ed39d47ec33ee3a"
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": "4f95cfb9576c411232503b26053ece6d8d186e4b337beff9bd4afd249f74a014"
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": "89229c882b6a3f5bb9ee542b31d1a40374b91879747582a3e82e8aadc72decb3"
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": "026e3031180ce9227b4d31775b9c886b10ad0cb418ed0754aed75b12ded84e31"
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": "d72e4363fd50d0fcc65fd23b40601516bd73368c3659f2a55a394c4d1ed3f85c"
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": "af04d1941fb7aed2cc51b05f65ffed1c440d4d23a7fb26e13261d45e07b0a1f8"
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": "9ad83ba418dee86ef680575a0cc7da4af4be3ae995c00f23f84b336838fbda47"
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": "45406dabeee0635ede5cda3d2f4144c5c13886d624287fd223b01252d2859b87"
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
- Authorize one exact three-resource Snake manifest on an existing cluster.
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 < 144 — needs the engine tool-profile before any delivery (ADR-005).',
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
 
@@ -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.keys()) {
85
- emit(errorResponse(JSON.parse(key), 'Cloud.ru engine connection closed'));
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 Snake workload.',
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 && !message.error) {
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.result = { isError: true, content: [{ type: 'text', text: 'deploy_apply returned an unreadable outcome; inspect the live VM inventory before retrying' }] };
238
- emit(message); closeIfDone(); return;
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.result = { isError: true, content: [{ type: 'text', text: 'deploy_apply returned an invalid outcome; inspect the live VM inventory before retrying' }] };
242
- emit(message); closeIfDone(); return;
252
+ emit(uncertainDeploy(message.id, approval, 'deploy_apply returned an invalid outcome'));
253
+ closeIfDone(); return;
243
254
  }
244
255
  const safe = redactDeploySecrets(deploy);
245
- if (!message.result?.isError && approval.siteMode && deploy.status === 'running' && deploy.http_verification?.verified === true) {
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. Inspect the returned VM UUID and live inventory before retrying; a fresh plan and approval may be needed.';
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, 144);
19
+ assert.equal(snapNames.length, 146);
20
20
  });
21
21
 
22
- test('counts are the reviewed golden split: 64 ask / 2 deny / 78 allow', () => {
23
- assert.deepEqual(cls.counts, { ask: 64, deny: 2, allow: 78 });
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, 78);
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, 78 * 2);
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);
@@ -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
- '4463b09323c9dfe0c32d618d335734a947e7fab0a75d07a7bea089b97a97c9b6',
128
+ '0095d3911da7e3a93f1d2ee064708820b9b9c50bfc5cbd99fc46c7da6db8be08',
129
129
  );
130
130
  });
@@ -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');