huaweicloud-devkit 0.1.24 → 0.1.26-dev.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/integrations/opencode/opencode.json +1 -1
- package/integrations/opencode/skills/huaweicloud-core/SKILL.md +2 -0
- package/package.json +1 -1
- package/plugins/huaweicloud-core/.claude-plugin/plugin.json +43 -43
- package/plugins/huaweicloud-core/.codex-plugin/plugin.json +42 -42
- package/plugins/huaweicloud-core/.cursor-plugin/plugin.json +43 -43
- package/plugins/huaweicloud-core/.mcp.json +1 -1
- package/plugins/huaweicloud-core/hooks/huaweicloud-safety.py +48 -0
- package/plugins/huaweicloud-core/safety/rules/cloud-risk-rules.json +178 -0
- package/plugins/huaweicloud-core/skills/huawei-cloud-find-skills/SKILL.md +1 -1
- package/plugins/huaweicloud-core/skills/huawei-dds-dcs/SKILL.md +2 -2
- package/plugins/huaweicloud-core/skills/huawei-ecs/SKILL.md +4 -0
- package/plugins/huaweicloud-core/skills/huawei-ecs/references/create-instance.md +21 -6
- package/plugins/huaweicloud-core/skills/huawei-ecs/references/flavors.md +28 -1
- package/plugins/huaweicloud-core/skills/huawei-functiongraph/SKILL.md +0 -1
- package/plugins/huaweicloud-core/skills/huawei-functiongraph/references/triggers.md +1 -1
- package/plugins/huaweicloud-core/skills/huawei-getting-started/SKILL.md +2 -2
- package/plugins/huaweicloud-core/skills/huawei-obs/SKILL.md +2 -11
- package/plugins/huaweicloud-core/skills/huawei-vpc/SKILL.md +4 -1
- package/plugins/huaweicloud-core/skills/huaweicloud-cli-and-auth/SKILL.md +5 -5
- package/plugins/huaweicloud-core/skills/huaweicloud-safety/SKILL.md +14 -0
- package/plugins/huaweicloud-core/src/mcp-server.mjs +39 -14
- package/plugins/huaweicloud-core/src/risk-rule-engine.mjs +137 -0
- package/plugins/huaweicloud-core/src/safety-policy.mjs +26 -13
- package/plugins/huaweicloud-core/src/setup-cli.mjs +126 -65
- package/plugins/huaweicloud-core/src/tools.mjs +156 -1
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
## 1. Discover flavors
|
|
6
6
|
hcloud ECS ListFlavors --cli-region=<region> --cli-output=json
|
|
7
|
+
Filter for `os_extra_specs.cond:operation:status == normal` — most results are abandoned. See references/flavors.md.
|
|
7
8
|
|
|
8
9
|
## 2. Find availability zones
|
|
9
10
|
hcloud ECS NovaListAvailabilityZones --cli-region=<region>
|
|
@@ -30,7 +31,7 @@ hcloud VPC ListSubnets --vpc_id=<vpc-id> --cli-region=<region>
|
|
|
30
31
|
If no VPC/subnet exists: load `huawei-vpc` skill → create VPC → create subnet (with DNS) → create security group → return here.
|
|
31
32
|
|
|
32
33
|
## 5. Create keypair (recommended over adminPass)
|
|
33
|
-
hcloud ECS NovaCreateKeypair --
|
|
34
|
+
hcloud ECS NovaCreateKeypair --keypair.name=<name>
|
|
34
35
|
Save the returned private key to a local file. The public key is auto-injected.
|
|
35
36
|
|
|
36
37
|
Password alternative:
|
|
@@ -43,7 +44,7 @@ hcloud ECS CreateServers --cli-region=<region> --server.name=<name> --server.fla
|
|
|
43
44
|
|
|
44
45
|
### Bootstrap with user_data (cloud-init)
|
|
45
46
|
|
|
46
|
-
Use `--server.user_data` to run a cloud-init script at first boot. The value must be **base64-encoded
|
|
47
|
+
Use `--server.user_data` to run a cloud-init script at first boot. The value must be **base64-encoded**. This is also the recommended bootstrap path when SCP policies block SSH access — user_data serves as the full deployment path, no SSH needed.
|
|
47
48
|
|
|
48
49
|
```bash
|
|
49
50
|
# Encode the script
|
|
@@ -58,20 +59,34 @@ hcloud ECS CreateServers ... --server.user_data=$user_data
|
|
|
58
59
|
```
|
|
59
60
|
|
|
60
61
|
> **Security**: Never embed secrets (passwords, AK/SK, tokens) in user_data. It is stored unencrypted and readable from within the instance via IMDS. Fetch secrets at boot from DEW/CSMS instead.
|
|
61
|
-
|
|
62
|
+
|
|
62
63
|
> **Debugging**: If the script didn't run, check `/var/log/cloud-init-output.log` on the instance.
|
|
63
64
|
|
|
64
|
-
## 7. EIP (
|
|
65
|
-
|
|
65
|
+
## 7. EIP (two methods)
|
|
66
|
+
|
|
67
|
+
### Method A: Inline with CreateServers (Recommended)
|
|
68
|
+
Add EIP parameters to the `CreateServers` command in step 6:
|
|
66
69
|
|
|
67
70
|
```bash
|
|
71
|
+
hcloud ECS CreateServers \
|
|
72
|
+
--server.publicip.eip.iptype=<type> \
|
|
73
|
+
--server.publicip.eip.bandwidth.sharetype=<share-type> \
|
|
74
|
+
--server.publicip.eip.bandwidth.size=<size> \
|
|
75
|
+
--server.publicip.eip.bandwidth.chargemode=traffic \
|
|
76
|
+
...
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
> **Trap**: Parameter names differ from `EIP CreatePublicip`. Use `iptype` (not `type`), `sharetype` (not `share_type`), and `chargemode` (not `charging_mode`). Always verify with `hcloud ECS CreateServers --help`.
|
|
80
|
+
|
|
81
|
+
### Method B: Create and bind separately
|
|
82
|
+
hcloud EIP CreatePublicip --publicip.type=<type> --bandwidth.size=<size> --bandwidth.share_type=<share-type> --bandwidth.name=<name>
|
|
83
|
+
|
|
68
84
|
# Get the ECS network port ID
|
|
69
85
|
hcloud ECS ListServersDetails --cli-region=<region> --server_id=<instance-id>
|
|
70
86
|
# → addresses.<vpc-id>[].OS-EXT-IPS:port_id
|
|
71
87
|
|
|
72
88
|
# Bind EIP via port
|
|
73
89
|
hcloud EIP AssociatePublicips --publicip_id=<eip-id> --publicip.associate_instance_id=<port-id> --publicip.associate_instance_type=PORT
|
|
74
|
-
```
|
|
75
90
|
|
|
76
91
|
## 8. Verify
|
|
77
92
|
hcloud ECS ListServersDetails --cli-region=<region> --server_id=<instance-id>
|
|
@@ -3,7 +3,20 @@
|
|
|
3
3
|
**Always discover flavors dynamically before recommending a specific flavor name.** Flavor availability varies by region and changes over time.
|
|
4
4
|
|
|
5
5
|
## Step 1: List available flavors
|
|
6
|
-
|
|
6
|
+
|
|
7
|
+
Use JMESPath to filter in-line — raw output returns hundreds of records and floods context:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Filter by family prefix (e.g. ac7), return name + specs only
|
|
11
|
+
hcloud ECS ListFlavors --cli-region=<region> --cli-output=json \
|
|
12
|
+
--cli-query="flavors[?contains(name, 'ac7')].{name:name, vcpus:vcpus, ram:ram}"
|
|
13
|
+
|
|
14
|
+
# Filter by vCPU range
|
|
15
|
+
hcloud ECS ListFlavors --cli-region=<region> --cli-output=json \
|
|
16
|
+
--cli-query="flavors[?vcpus >= '2' && vcpus <= '4'].{name:name, vcpus:vcpus, ram:ram}"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
> Always use `--cli-query` with JMESPath to narrow results. Never run bare `ListFlavors` without filtering.
|
|
7
20
|
|
|
8
21
|
## Step 2: Filter by scenario
|
|
9
22
|
|
|
@@ -26,3 +39,17 @@ Flavor family names are region-dependent. Example discrepancies seen in testing:
|
|
|
26
39
|
- Other regions may have s6/m6/g6 families
|
|
27
40
|
|
|
28
41
|
Always run ListFlavors and pick from actual results.
|
|
42
|
+
|
|
43
|
+
## Step 3: Filter out abandoned / sold-out specs
|
|
44
|
+
|
|
45
|
+
`ListFlavors` returns ALL specs including abandoned ones. Before selecting a spec, check the `os_extra_specs` field in the JSON response:
|
|
46
|
+
|
|
47
|
+
| Field | Values | Meaning |
|
|
48
|
+
|-------|--------|---------|
|
|
49
|
+
| `os_extra_specs.cond:operation:status` | `normal`, `abandon`, `sellout` | Only `normal` specs can be created. `abandon` = deprecated, `sellout` = out of stock |
|
|
50
|
+
| `os_extra_specs.cond:operation:az` | e.g. `cn-north-4g(normal)` | Spec is available in this AZ. Multiple entries = multiple AZ support |
|
|
51
|
+
|
|
52
|
+
A flavor can be `normal` globally but `abandon` in specific AZs. Selecting an `abandon` or `sellout` spec will fail with **`Ecs.0019`** at creation time — there is no pre-flight validation in `ListFlavors`. If creation fails:
|
|
53
|
+
|
|
54
|
+
1. Switch to a different AZ: `hcloud ECS NovaListAvailabilityZones --cli-region=<region>`
|
|
55
|
+
2. Or switch to a different flavor family (e.g., `at7` → `ac7`)
|
|
@@ -63,7 +63,6 @@ Always discover parameters with `--help` before executing. These are the correct
|
|
|
63
63
|
| Create function | `CreateFunction` | references/create-function.md |
|
|
64
64
|
| Delete function | `DeleteFunction` | Strip `:latest` from URN |
|
|
65
65
|
| Invoke function | `InvokeFunction` | Requires body param (`--name=<value>` becomes event body). Use `--x_cff_request_version=v0` for raw output, `v1` for APIG-wrapped. |
|
|
66
|
-
| List runtimes | `ListRuntimes` | |
|
|
67
66
|
| Create trigger | `CreateFunctionTrigger` | references/triggers.md |
|
|
68
67
|
| List triggers | `ListFunctionTriggers` | |
|
|
69
68
|
| Delete trigger | `DeleteFunctionTrigger` | |
|
|
@@ -138,5 +138,5 @@ hcloud FunctionGraph DeleteFunctionTrigger --function_urn=<urn> --trigger_type_c
|
|
|
138
138
|
```
|
|
139
139
|
|
|
140
140
|
> Deleting a trigger does NOT cascade-delete the associated APIG API. After `DeleteFunctionTrigger`, also run `hcloud APIG ListApisV2 --instance_id=<id>` and delete orphaned APIs to avoid resource residue.
|
|
141
|
-
|
|
141
|
+
|
|
142
142
|
> **APIG event format**: When using DEDICATEDGATEWAY, the event body is Base64 encoded and uses a non-standard structure. See `apig-event-format.md` for handler templates.
|
|
@@ -18,7 +18,7 @@ version: 1
|
|
|
18
18
|
|
|
19
19
|
## First-Time Setup
|
|
20
20
|
1. **Install KooCLI** using command above
|
|
21
|
-
2. **Accept privacy policy** (first run only): KooCLI requires one-time privacy agreement. Run `hcloud version`
|
|
21
|
+
2. **Accept privacy policy** (first run only): KooCLI requires one-time privacy agreement. Run `hcloud version` to read the agreement, then respond `y` to accept. Do not pipe `echo "y" |` — you must review the terms first.
|
|
22
22
|
3. **Configure credentials**: `hcloud configure init` (interactive, prompts for AK/SK/region safely)
|
|
23
23
|
4. **Verify**: `hcloud configure list` to confirm profile, then `hcloud ECS ListServersDetails --cli-region=cn-north-4`
|
|
24
24
|
5. For detailed auth guidance, see `huaweicloud-cli-and-auth` skill
|
|
@@ -43,7 +43,7 @@ hcloud configure list
|
|
|
43
43
|
| AK/SK must be kept secret | Never commit to git or share |
|
|
44
44
|
| Default region applies to all commands | Override with --cli-region= per command |
|
|
45
45
|
| Some services region-specific | Not all services available in all regions |
|
|
46
|
-
| Privacy policy blocks first run |
|
|
46
|
+
| Privacy policy blocks first run | Run `hcloud version` to read and accept the agreement. Review the terms before responding `y` |
|
|
47
47
|
|
|
48
48
|
## What Can I Do? (Quick Index)
|
|
49
49
|
| Goal | Skill |
|
|
@@ -36,22 +36,13 @@ Domain expertise for Huawei Cloud Object Storage Service (OBS). Covers bucket/ob
|
|
|
36
36
|
| Versioning is irreversible | Once enabled, cannot be disabled, only suspended |
|
|
37
37
|
| OBS uses AK/SK directly | NOT IAM tokens. Auth errors mean check AK/SK validity |
|
|
38
38
|
| Static website via CLI missing | KooCLI OBS lacks website config. Use REST API or console |
|
|
39
|
-
| **OBS needs separate cred config** | `hcloud configure` is NOT enough for OBS.
|
|
39
|
+
| **OBS needs separate cred config** | `hcloud configure` is NOT enough for OBS. Before any OBS operation, call `huaweicloud_setup_obs_config` to sync credentials from hcloud profile. |
|
|
40
40
|
| **obsutil interactive prompts** | `cp`/`rm` without `-f` causes "Please input (y/n)" → Agent hangs (TIMEOUT). Always use `-f` for non-interactive. |
|
|
41
41
|
| **Directory upload adds prefix** | `cp <dir>/ obs://<bucket>/ -r` puts files under `bucket/<dir>/...`. Use `-flat` for root-level files (static sites). Preview with `-dryRun` first. |
|
|
42
42
|
|
|
43
43
|
## OBS Credential Setup (Required Before First Use)
|
|
44
44
|
|
|
45
|
-
KooCLI OBS uses a separate config file (`~/.obsutilconfig`), NOT `~/.hcloud/config.json`.
|
|
46
|
-
|
|
47
|
-
```bash
|
|
48
|
-
# Preferred: interactive (safe, no AK/SK in shell history)
|
|
49
|
-
hcloud OBS config -i
|
|
50
|
-
# Follow prompts: AK, SK, endpoint (e.g. obs.cn-north-4.myhuaweicloud.com)
|
|
51
|
-
|
|
52
|
-
# Alternative: non-interactive (use with caution — AK/SK in shell history)
|
|
53
|
-
hcloud OBS config -e=<endpoint> -i=<AK> -k=<SK>
|
|
54
|
-
```
|
|
45
|
+
KooCLI OBS uses a separate config file (`~/.obsutilconfig`), NOT `~/.hcloud/config.json`. Call `huaweicloud_setup_obs_config` to automatically sync credentials from the active hcloud profile. No manual AK/SK entry needed.
|
|
55
46
|
|
|
56
47
|
## Common Workflows
|
|
57
48
|
|
|
@@ -10,6 +10,8 @@ version: 1
|
|
|
10
10
|
|
|
11
11
|
Always run `hcloud <Service> <Operation> --help` before constructing commands to discover exact parameter names and requirements.
|
|
12
12
|
|
|
13
|
+
> **Multi-version APIs**: KooCLI may print a warning like "ListVpcs有多个版本,默认使用该API版本v3" before the actual response. The text BEFORE the first `{` is the version selection notice — parse JSON starting from `{` only. This is normal behavior, not an error.
|
|
14
|
+
|
|
13
15
|
## Overview
|
|
14
16
|
|
|
15
17
|
Domain expertise for Huawei Cloud Virtual Private Cloud (VPC). Covers VPC/subnet lifecycle, security groups, EIP management, NAT gateways, VPN, and network ACLs.
|
|
@@ -27,6 +29,7 @@ Domain expertise for Huawei Cloud Virtual Private Cloud (VPC). Covers VPC/subnet
|
|
|
27
29
|
| **VPC params need nested prefix** | KooCLI 7.x VPC API uses `--vpc.<param>`, `--subnet.<param>`, `--security_group.<param>`. Example: `--vpc.name=xxx` NOT `--name=xxx` |
|
|
28
30
|
| **Security group needs no vpc_id** | VPC v3 API `CreateSecurityGroup` does NOT accept `vpc_id`. Security groups are region-level, not VPC-bound |
|
|
29
31
|
| Subnet DNS empty → ECS no DNS | DNS params (`--subnet.primary_dns`, `--subnet.secondary_dns`) marked optional but empty default breaks cloud-init domain resolution — `yum`/`apt` installs fail silently. Always set both. See `--help` for region-specific DNS IPs |
|
|
32
|
+
| SCP blocks 0.0.0.0/0 SG rules | If `CreateSecurityGroupRule` with `--remote_ip_prefix=0.0.0.0/0` fails with `SYS.0403`, an org-level SCP policy is denying wide-open rules. Narrow to a specific CIDR range (e.g., your office IP) instead |
|
|
30
33
|
|
|
31
34
|
## Common Workflows
|
|
32
35
|
|
|
@@ -55,7 +58,7 @@ Domain expertise for Huawei Cloud Virtual Private Cloud (VPC). Covers VPC/subnet
|
|
|
55
58
|
| VPC.0301: Bandwidth name invalid | PER type requires `--bandwidth.name`, even though `--help` marks it optional |
|
|
56
59
|
| EIP has no public IP after binding | May need AddIngressEipV2 for ELB-type resources (see huawei-apig) |
|
|
57
60
|
| ECS cloud-init fails silently (port 80/443 closed) | Subnet likely has no DNS. Check `hcloud VPC ShowSubnet --subnet_id=<id>` → `dnsList` empty? Rebuild subnet with `--subnet.primary_dns=<dns1> --subnet.secondary_dns=<dns2>`. DNS addresses per region: `hcloud VPC CreateSubnet --help` |
|
|
58
|
-
| SYS.0403 / SCP deny | Service Control Policy explicitly denies this operation — contact org admin to adjust SCP, or use an account/region without the restriction |
|
|
61
|
+
| SYS.0403 / SCP deny | Service Control Policy explicitly denies this operation — contact org admin to adjust SCP, or use an account/region without the restriction. If SSH is blocked, bootstrap via cloud-init user_data instead: see `huawei-ecs` — no SSH needed |
|
|
59
62
|
|
|
60
63
|
## Security Considerations
|
|
61
64
|
|
|
@@ -72,14 +72,14 @@ Agent processes find executables through `PATH`. If OpenCode/Codex cannot find `
|
|
|
72
72
|
2. **KooCLI first-run privacy agreement**: On a fresh KooCLI install, `hcloud` blocks with `同意并继续使用(y)/不同意并退出(N)` and fails with `[USE_ERROR]您输入的是无效字符` in non-interactive mode. Detection: check command output for these strings. Ask the user: "KooCLI needs to accept its privacy agreement. May I accept it on your behalf?" If the user agrees, run `huaweicloud_run_readonly_command` with `args=["version"]` and `stdin="y\n"`. This accepts the agreement once, after which hcloud works normally.
|
|
73
73
|
3. Ask the user to configure credentials outside the agent conversation when setup is needed.
|
|
74
74
|
4. Inspect profile and region only through redacted tooling.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
4. Discover exact operation names with `hcloud <Service> --help` before guessing. Example: ECS instance listing is commonly `ECS ListServersDetails`; ECS creation is commonly `ECS CreateServers`; image lookup may be under `IMS GlanceShowImage`.
|
|
76
|
+
5. Use `--cli-output=json` for machine-readable responses when supported.
|
|
77
|
+
6. For resource operations, include `--cli-region`, `--cli-profile`, and service-specific project information when required.
|
|
78
|
+
7. Classify every command before running it:
|
|
79
79
|
- Read-only: `List*`, `Show*`, `Get*`, `Describe*`.
|
|
80
80
|
- Write: `Create*`, `Delete*`, `Update*`, `Resize*`, `Start*`, `Stop*`, `Authorize*`, and similar.
|
|
81
81
|
- Secret: any operation returning secret string, binary secret, token, or password.
|
|
82
|
-
|
|
82
|
+
8. For write operations, show the exact command and ask for explicit approval.
|
|
83
83
|
|
|
84
84
|
## KooCLI Syntax Notes
|
|
85
85
|
|
|
@@ -38,6 +38,20 @@ Use this skill before any Huawei Cloud action that may expose secrets, change re
|
|
|
38
38
|
- Approved tool path: use `huaweicloud_run_approved_command` only when the exact planned command has been shown and the user explicitly approved that exact command.
|
|
39
39
|
- After a write, verify with `huaweicloud_run_readonly_command` or another read-only check.
|
|
40
40
|
|
|
41
|
+
## Proactive Hook Checks
|
|
42
|
+
|
|
43
|
+
Before executing, deploying, or handing generated cloud artifacts to the user, run the matching check when available:
|
|
44
|
+
|
|
45
|
+
- `huaweicloud_hook_check_command` for shell or KooCLI command text.
|
|
46
|
+
- `huaweicloud_hook_check_artifacts` for generated policy, IaC, config, workflow, or deployment files.
|
|
47
|
+
- `huaweicloud_hook_check_deploy_plan` for sandbox, preview, FunctionGraph, ECS, CCE, APIG, OBS, IAM, or cost-affecting plans.
|
|
48
|
+
|
|
49
|
+
If the result is `deny`, repair the command, artifact, or deployment plan before execution. If the result is `warn`, show the warning to the user and repair or ask for explicit confirmation.
|
|
50
|
+
|
|
51
|
+
## Static Analysis Boundary
|
|
52
|
+
|
|
53
|
+
Hook checks inspect the command or plan text that is available before execution. If a command builds cloud parameters through shell variables, string concatenation, subshells, encoded payloads, or generated scripts, first expand the final command into reviewable text and run `huaweicloud_hook_check_command` on that final form. When the final values cannot be determined, stop and ask the user to review the expanded command or use `huaweicloud_plan_cli_command` before execution.
|
|
54
|
+
|
|
41
55
|
## Safe Alternatives
|
|
42
56
|
|
|
43
57
|
- Use redacted profile inspection instead of raw `hcloud configure show`.
|
|
@@ -3,6 +3,7 @@ import { stdin, stdout } from 'node:process';
|
|
|
3
3
|
import { TOOL_DEFINITIONS, callTool } from './tools.mjs';
|
|
4
4
|
|
|
5
5
|
let buffer = Buffer.alloc(0);
|
|
6
|
+
let useContentLengthFraming = true;
|
|
6
7
|
|
|
7
8
|
stdin.on('data', (chunk) => {
|
|
8
9
|
buffer = Buffer.concat([buffer, chunk]);
|
|
@@ -12,23 +13,43 @@ stdin.on('data', (chunk) => {
|
|
|
12
13
|
function readFrames() {
|
|
13
14
|
while (true) {
|
|
14
15
|
const headerEnd = buffer.indexOf('\r\n\r\n');
|
|
15
|
-
if (headerEnd
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return;
|
|
16
|
+
if (headerEnd !== -1) {
|
|
17
|
+
useContentLengthFraming = true;
|
|
18
|
+
const consumed = parseContentLengthFrame(headerEnd);
|
|
19
|
+
if (!consumed) return;
|
|
20
|
+
continue;
|
|
21
21
|
}
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
|
|
23
|
+
const lf = buffer.indexOf('\n');
|
|
24
|
+
if (lf !== -1) {
|
|
25
|
+
useContentLengthFraming = false;
|
|
26
|
+
const line = buffer.subarray(0, lf).toString('utf8').trim();
|
|
27
|
+
buffer = buffer.subarray(lf + 1);
|
|
28
|
+
if (line) void handleMessage(JSON.parse(line));
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return;
|
|
29
33
|
}
|
|
30
34
|
}
|
|
31
35
|
|
|
36
|
+
function parseContentLengthFrame(headerEnd) {
|
|
37
|
+
const header = buffer.subarray(0, headerEnd).toString('utf8');
|
|
38
|
+
const match = header.match(/Content-Length:\s*(\d+)/i);
|
|
39
|
+
if (!match) {
|
|
40
|
+
buffer = Buffer.alloc(0);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
const length = Number(match[1]);
|
|
44
|
+
const bodyStart = headerEnd + 4;
|
|
45
|
+
const bodyEnd = bodyStart + length;
|
|
46
|
+
if (buffer.length < bodyEnd) return false;
|
|
47
|
+
const body = buffer.subarray(bodyStart, bodyEnd).toString('utf8');
|
|
48
|
+
buffer = buffer.subarray(bodyEnd);
|
|
49
|
+
void handleMessage(JSON.parse(body));
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
32
53
|
async function handleMessage(message) {
|
|
33
54
|
if (!Object.hasOwn(message, 'id')) {
|
|
34
55
|
if (message.method === 'notifications/initialized') return;
|
|
@@ -89,5 +110,9 @@ async function dispatch(method, params) {
|
|
|
89
110
|
|
|
90
111
|
function writeMessage(message) {
|
|
91
112
|
const json = JSON.stringify(message);
|
|
92
|
-
|
|
113
|
+
if (useContentLengthFraming) {
|
|
114
|
+
stdout.write(`Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n${json}`);
|
|
115
|
+
} else {
|
|
116
|
+
stdout.write(json + '\n');
|
|
117
|
+
}
|
|
93
118
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const defaultRulesPath = join(__dirname, '..', 'safety', 'rules', 'cloud-risk-rules.json');
|
|
7
|
+
|
|
8
|
+
const SEVERITY_RANK = {
|
|
9
|
+
deny: 3,
|
|
10
|
+
warn: 2,
|
|
11
|
+
info: 1,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function loadRiskRules(options = {}) {
|
|
15
|
+
const path = options.path || defaultRulesPath;
|
|
16
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function redactEvidence(text) {
|
|
20
|
+
return String(text)
|
|
21
|
+
.replace(/((?:access[_-]?key|secret[_-]?key|security[_-]?token|x[_-]?auth[_-]?token|authorization|password|passwd|adminPass|credential)\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;]+)/gi, '$1<redacted>')
|
|
22
|
+
.replace(/(AK|SK)\s*[:=]\s*("[^"]*"|'[^']*'|[^\s,;]+)/g, '$1=<redacted>');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeText(value) {
|
|
26
|
+
if (typeof value === 'string') return value;
|
|
27
|
+
return JSON.stringify(value, null, 2);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function evaluationContext(stage, input) {
|
|
31
|
+
if (stage === 'command') {
|
|
32
|
+
const command = normalizeText(input.command || input.text || '');
|
|
33
|
+
return { text: command, command };
|
|
34
|
+
}
|
|
35
|
+
if (stage === 'artifact') {
|
|
36
|
+
const path = String(input.path || '');
|
|
37
|
+
const content = normalizeText(input.content || '');
|
|
38
|
+
return { text: `${path}\n${content}`, path, content };
|
|
39
|
+
}
|
|
40
|
+
if (stage === 'deploy_plan') {
|
|
41
|
+
const plan = normalizeText(input.plan || input.text || input);
|
|
42
|
+
return { text: plan, plan };
|
|
43
|
+
}
|
|
44
|
+
return { text: normalizeText(input) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function conditionMatches(condition, context) {
|
|
48
|
+
const field = condition.field || 'text';
|
|
49
|
+
const value = Object.hasOwn(context, field) ? context[field] : context.text;
|
|
50
|
+
return new RegExp(condition.regex, 'ims').test(String(value || ''));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ruleMatches(rule, context) {
|
|
54
|
+
const match = rule.match || {};
|
|
55
|
+
const all = match.all;
|
|
56
|
+
const any = match.any;
|
|
57
|
+
const none = match.none;
|
|
58
|
+
if (Array.isArray(all) && !all.every((condition) => conditionMatches(condition, context))) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
if (Array.isArray(any) && !any.some((condition) => conditionMatches(condition, context))) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
if (Array.isArray(none) && none.some((condition) => conditionMatches(condition, context))) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return Array.isArray(all) || Array.isArray(any);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function excerpt(text) {
|
|
71
|
+
const compact = redactEvidence(String(text).replace(/\s+/g, ' ').trim());
|
|
72
|
+
if (compact.length <= 240) return compact;
|
|
73
|
+
return `${compact.slice(0, 237)}...`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function evaluate(stage, inputs, options = {}) {
|
|
77
|
+
const catalog = options.catalog || loadRiskRules(options);
|
|
78
|
+
const items = Array.isArray(inputs) ? inputs : [inputs];
|
|
79
|
+
const findings = [];
|
|
80
|
+
|
|
81
|
+
for (const input of items) {
|
|
82
|
+
const context = evaluationContext(stage, input || {});
|
|
83
|
+
for (const rule of catalog.rules) {
|
|
84
|
+
if (!rule.stages.includes(stage)) continue;
|
|
85
|
+
if (!ruleMatches(rule, context)) continue;
|
|
86
|
+
findings.push({
|
|
87
|
+
ruleId: rule.id,
|
|
88
|
+
title: rule.title,
|
|
89
|
+
category: rule.category,
|
|
90
|
+
severity: rule.severity,
|
|
91
|
+
message: rule.message,
|
|
92
|
+
remediation: rule.remediation,
|
|
93
|
+
source: input?.path || stage,
|
|
94
|
+
evidence: excerpt(context.text),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
findings.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]);
|
|
100
|
+
const hasDeny = findings.some((finding) => finding.severity === 'deny');
|
|
101
|
+
const hasWarn = findings.some((finding) => finding.severity === 'warn');
|
|
102
|
+
return {
|
|
103
|
+
decision: hasDeny ? 'deny' : hasWarn ? 'warn' : 'allow',
|
|
104
|
+
findings,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function evaluateCommandRisk(command, options = {}) {
|
|
109
|
+
return evaluate('command', { command }, options);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function evaluateArtifacts(artifacts, options = {}) {
|
|
113
|
+
return evaluate('artifact', Array.isArray(artifacts) ? artifacts : [], options);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function evaluateDeployPlan(plan, options = {}) {
|
|
117
|
+
return evaluate('deploy_plan', { plan }, options);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function mergeRiskDecision(base, risk) {
|
|
121
|
+
if (!risk || !risk.findings?.length) return base;
|
|
122
|
+
if (risk.decision === 'deny') {
|
|
123
|
+
const topFinding = risk.findings[0];
|
|
124
|
+
return {
|
|
125
|
+
...base,
|
|
126
|
+
decision: 'deny',
|
|
127
|
+
risk: topFinding.category,
|
|
128
|
+
reason: topFinding.message,
|
|
129
|
+
blockedByRiskRule: true,
|
|
130
|
+
findings: risk.findings,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
...base,
|
|
135
|
+
warnings: [...(base.warnings || []), ...risk.findings],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { evaluateCommandRisk, mergeRiskDecision } from './risk-rule-engine.mjs';
|
|
4
5
|
|
|
5
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
7
|
const policyPath = join(__dirname, '..', 'safety', 'policy.json');
|
|
@@ -92,6 +93,18 @@ function isLocalMetadataCommand(args) {
|
|
|
92
93
|
return args.some((arg) => /^(--help|-h|help|version|--version)$/i.test(String(arg)));
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
function commandRiskText(normalizedArgs, options = {}) {
|
|
97
|
+
return options.rawCommand || ['hcloud', ...normalizedArgs].join(' ');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function applyCommandRiskRules(base, normalizedArgs, options = {}) {
|
|
101
|
+
if (base.decision === 'deny' || options.skipRiskRules === true) {
|
|
102
|
+
return base;
|
|
103
|
+
}
|
|
104
|
+
const risk = evaluateCommandRisk(commandRiskText(normalizedArgs, options));
|
|
105
|
+
return mergeRiskDecision(base, risk);
|
|
106
|
+
}
|
|
107
|
+
|
|
95
108
|
export function classifyHcloudArgs(args, options = {}) {
|
|
96
109
|
const policy = options.policy || DEFAULT_POLICY;
|
|
97
110
|
const { service, operation, args: normalizedArgs } = commandOperation(args);
|
|
@@ -106,14 +119,14 @@ export function classifyHcloudArgs(args, options = {}) {
|
|
|
106
119
|
}
|
|
107
120
|
|
|
108
121
|
if (isLocalMetadataCommand(normalizedArgs)) {
|
|
109
|
-
return {
|
|
122
|
+
return applyCommandRiskRules({
|
|
110
123
|
decision: 'allow',
|
|
111
124
|
risk: 'local_metadata',
|
|
112
125
|
reason: 'KooCLI local help and version commands are read-only and do not call Huawei Cloud resource APIs.',
|
|
113
126
|
service,
|
|
114
127
|
operation,
|
|
115
128
|
args: normalizedArgs,
|
|
116
|
-
};
|
|
129
|
+
}, normalizedArgs, options);
|
|
117
130
|
}
|
|
118
131
|
|
|
119
132
|
if (service.toLowerCase() === 'configure') {
|
|
@@ -180,49 +193,49 @@ export function classifyHcloudArgs(args, options = {}) {
|
|
|
180
193
|
};
|
|
181
194
|
}
|
|
182
195
|
if (isObsRead) {
|
|
183
|
-
return {
|
|
196
|
+
return applyCommandRiskRules({
|
|
184
197
|
decision: 'allow',
|
|
185
198
|
risk: 'read_only',
|
|
186
199
|
reason: 'OBS read-only operation.',
|
|
187
200
|
service,
|
|
188
201
|
operation,
|
|
189
202
|
args: normalizedArgs,
|
|
190
|
-
};
|
|
203
|
+
}, normalizedArgs, options);
|
|
191
204
|
}
|
|
192
205
|
if (isObsWrite && options.allowWrites) {
|
|
193
|
-
return {
|
|
206
|
+
return applyCommandRiskRules({
|
|
194
207
|
decision: 'allow',
|
|
195
208
|
risk: 'write',
|
|
196
209
|
reason: 'OBS write operation approved by user.',
|
|
197
210
|
service,
|
|
198
211
|
operation,
|
|
199
212
|
args: normalizedArgs,
|
|
200
|
-
};
|
|
213
|
+
}, normalizedArgs, options);
|
|
201
214
|
}
|
|
202
215
|
|
|
203
216
|
if (isExecution && options.allowWrites) {
|
|
204
|
-
return {
|
|
217
|
+
return applyCommandRiskRules({
|
|
205
218
|
decision: 'allow',
|
|
206
219
|
risk: 'execution',
|
|
207
220
|
reason: 'Huawei Cloud execution/trigger operation approved by user.',
|
|
208
221
|
service,
|
|
209
222
|
operation,
|
|
210
223
|
args: normalizedArgs,
|
|
211
|
-
};
|
|
224
|
+
}, normalizedArgs, options);
|
|
212
225
|
}
|
|
213
226
|
|
|
214
227
|
if (isWrite && options.allowWrites) {
|
|
215
|
-
return {
|
|
228
|
+
return applyCommandRiskRules({
|
|
216
229
|
decision: 'allow',
|
|
217
230
|
risk: 'write',
|
|
218
231
|
reason: 'Huawei Cloud write operation approved by user.',
|
|
219
232
|
service,
|
|
220
233
|
operation,
|
|
221
234
|
args: normalizedArgs,
|
|
222
|
-
};
|
|
235
|
+
}, normalizedArgs, options);
|
|
223
236
|
}
|
|
224
237
|
|
|
225
|
-
return {
|
|
238
|
+
return applyCommandRiskRules({
|
|
226
239
|
decision: 'allow',
|
|
227
240
|
risk: readOnly ? 'read_only' : 'unknown_read',
|
|
228
241
|
reason: readOnly
|
|
@@ -231,7 +244,7 @@ export function classifyHcloudArgs(args, options = {}) {
|
|
|
231
244
|
service,
|
|
232
245
|
operation,
|
|
233
246
|
args: normalizedArgs,
|
|
234
|
-
};
|
|
247
|
+
}, normalizedArgs, options);
|
|
235
248
|
}
|
|
236
249
|
|
|
237
250
|
function splitSimpleCommand(command) {
|
|
@@ -261,7 +274,7 @@ export function classifyTextCommand(command, options = {}) {
|
|
|
261
274
|
}
|
|
262
275
|
|
|
263
276
|
if (/(^|\s)hcloud(\.exe)?\s+/i.test(text)) {
|
|
264
|
-
return classifyHcloudArgs(splitSimpleCommand(text), options);
|
|
277
|
+
return classifyHcloudArgs(splitSimpleCommand(text), { ...options, rawCommand: text });
|
|
265
278
|
}
|
|
266
279
|
|
|
267
280
|
if (/ShowSecretVersion|GetSecretValue|secret_string|secret_binary/i.test(text)) {
|