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.
Files changed (27) hide show
  1. package/README.md +5 -2
  2. package/integrations/opencode/opencode.json +1 -1
  3. package/integrations/opencode/skills/huaweicloud-core/SKILL.md +2 -0
  4. package/package.json +1 -1
  5. package/plugins/huaweicloud-core/.claude-plugin/plugin.json +43 -43
  6. package/plugins/huaweicloud-core/.codex-plugin/plugin.json +42 -42
  7. package/plugins/huaweicloud-core/.cursor-plugin/plugin.json +43 -43
  8. package/plugins/huaweicloud-core/.mcp.json +1 -1
  9. package/plugins/huaweicloud-core/hooks/huaweicloud-safety.py +48 -0
  10. package/plugins/huaweicloud-core/safety/rules/cloud-risk-rules.json +178 -0
  11. package/plugins/huaweicloud-core/skills/huawei-cloud-find-skills/SKILL.md +1 -1
  12. package/plugins/huaweicloud-core/skills/huawei-dds-dcs/SKILL.md +2 -2
  13. package/plugins/huaweicloud-core/skills/huawei-ecs/SKILL.md +4 -0
  14. package/plugins/huaweicloud-core/skills/huawei-ecs/references/create-instance.md +21 -6
  15. package/plugins/huaweicloud-core/skills/huawei-ecs/references/flavors.md +28 -1
  16. package/plugins/huaweicloud-core/skills/huawei-functiongraph/SKILL.md +0 -1
  17. package/plugins/huaweicloud-core/skills/huawei-functiongraph/references/triggers.md +1 -1
  18. package/plugins/huaweicloud-core/skills/huawei-getting-started/SKILL.md +2 -2
  19. package/plugins/huaweicloud-core/skills/huawei-obs/SKILL.md +2 -11
  20. package/plugins/huaweicloud-core/skills/huawei-vpc/SKILL.md +4 -1
  21. package/plugins/huaweicloud-core/skills/huaweicloud-cli-and-auth/SKILL.md +5 -5
  22. package/plugins/huaweicloud-core/skills/huaweicloud-safety/SKILL.md +14 -0
  23. package/plugins/huaweicloud-core/src/mcp-server.mjs +39 -14
  24. package/plugins/huaweicloud-core/src/risk-rule-engine.mjs +137 -0
  25. package/plugins/huaweicloud-core/src/safety-policy.mjs +26 -13
  26. package/plugins/huaweicloud-core/src/setup-cli.mjs +126 -65
  27. 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 --keypair_name=<name>
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 (optional)
65
- hcloud EIP CreatePublicip --publicip.type=<type> --bandwidth.size=<size> --bandwidth.share_type=<share-type> --bandwidth.name=<name>
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
- hcloud ECS ListFlavors --cli-region=<region> --cli-output=json
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` and respond `y` to the prompt. For non-interactive terminals, use `echo "y" | 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 | KooCLI requires one-time `y` confirmation. Non-interactive terminals need `echo "y" \| hcloud <cmd>` |
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. Run `hcloud OBS config -i` (interactive) to create `~/.obsutilconfig`. This must be done OUTSIDE agent chat. |
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`. Use the **same AK/SK** that you configured for hcloud. Run once outside agent chat:
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
- 5. 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
- 6. Use `--cli-output=json` for machine-readable responses when supported.
77
- 7. For resource operations, include `--cli-region`, `--cli-profile`, and service-specific project information when required.
78
- 8. Classify every command before running it:
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
- 9. For write operations, show the exact command and ask for explicit approval.
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 === -1) return;
16
- const header = buffer.subarray(0, headerEnd).toString('utf8');
17
- const match = header.match(/Content-Length:\s*(\d+)/i);
18
- if (!match) {
19
- buffer = Buffer.alloc(0);
20
- return;
16
+ if (headerEnd !== -1) {
17
+ useContentLengthFraming = true;
18
+ const consumed = parseContentLengthFrame(headerEnd);
19
+ if (!consumed) return;
20
+ continue;
21
21
  }
22
- const length = Number(match[1]);
23
- const bodyStart = headerEnd + 4;
24
- const bodyEnd = bodyStart + length;
25
- if (buffer.length < bodyEnd) return;
26
- const body = buffer.subarray(bodyStart, bodyEnd).toString('utf8');
27
- buffer = buffer.subarray(bodyEnd);
28
- void handleMessage(JSON.parse(body));
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
- stdout.write(`Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n${json}`);
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)) {