create-windy 0.2.30 → 0.2.32

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 (29) hide show
  1. package/dist/cli.js +11 -4
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/apps/agent-server/README.md +4 -2
  5. package/template/apps/agent-server/src/southwind_agent_server/config.py +48 -0
  6. package/template/apps/agent-server/src/southwind_agent_server/contracts.py +22 -2
  7. package/template/apps/agent-server/src/southwind_agent_server/engine.py +10 -1
  8. package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +20 -2
  9. package/template/apps/agent-server/src/southwind_agent_server/service.py +2 -0
  10. package/template/apps/agent-server/tests/test_api.py +28 -0
  11. package/template/apps/agent-server/tests/test_config.py +46 -0
  12. package/template/apps/agent-server/tests/test_openai_provider.py +61 -1
  13. package/template/apps/server/src/agent/remote-runtime.test.ts +14 -0
  14. package/template/apps/server/src/agent/remote-runtime.ts +2 -0
  15. package/template/apps/server/src/agent/run-contracts.ts +6 -0
  16. package/template/apps/server/src/agent/run-facade.ts +6 -0
  17. package/template/apps/server/src/agent/tool-host.test.ts +25 -0
  18. package/template/apps/server/src/agent/tool-host.ts +10 -1
  19. package/template/docker-compose.yml +1 -0
  20. package/template/docs/architecture/ai-egress.md +80 -0
  21. package/template/docs/architecture/ai-runtime.md +13 -1
  22. package/template/docs/platform/agent-runtime.md +20 -1
  23. package/template/packages/config/index.test.ts +20 -0
  24. package/template/packages/config/src/ai-openai-compatible.ts +58 -2
  25. package/template/packages/modules/index.ts +1 -0
  26. package/template/packages/modules/src/ai-network-policy.ts +50 -0
  27. package/template/packages/modules/src/ai-operation-composition.test.ts +50 -0
  28. package/template/packages/modules/src/ai-operation-composition.ts +2 -0
  29. package/template/packages/modules/src/manifest.ts +13 -1
package/dist/cli.js CHANGED
@@ -1114,7 +1114,7 @@ function rewriteCustomerDockerfile(source) {
1114
1114
  return !supplierWorkspaceCopies.includes(line) && !supplierWorkspacePaths.some((path) => line.includes(path)) && !line.includes("deploy/production/server-entrypoint.sh");
1115
1115
  });
1116
1116
  return lines.join(`
1117
- `).replace("COPY package.json bun.lock ./", "COPY package.json ./").replace("RUN bun install --frozen-lockfile", "RUN bun install");
1117
+ `);
1118
1118
  }
1119
1119
 
1120
1120
  // src/code-quality.ts
@@ -1585,6 +1585,7 @@ var agentComposeService = ` agent-server:
1585
1585
  QWEN_API_URL: \${QWEN_API_URL:-}
1586
1586
  QWEN_API_KEY: \${QWEN_API_KEY:-}
1587
1587
  QWEN_MODEL: \${QWEN_MODEL:-}
1588
+ AI_PROVIDER_HTTPS_HOST_ALLOWLIST: \${AI_PROVIDER_HTTPS_HOST_ALLOWLIST:-}
1588
1589
  AI_PROVIDER_CONNECT_TIMEOUT_MS: \${AI_PROVIDER_CONNECT_TIMEOUT_MS:-5000}
1589
1590
  AI_PROVIDER_REQUEST_TIMEOUT_MS: \${AI_PROVIDER_REQUEST_TIMEOUT_MS:-60000}
1590
1591
  AI_PROVIDER_MAX_RESPONSE_BYTES: \${AI_PROVIDER_MAX_RESPONSE_BYTES:-2097152}
@@ -1606,12 +1607,13 @@ function environmentExample(includeExample, includeAgent = true) {
1606
1607
  POSTGRES_PASSWORD=
1607
1608
  WINDY_BOOTSTRAP_ADMIN_PASSWORD=
1608
1609
  ${includeAgent ? `
1609
- # Agent profile 仅由 Server 读取;不要使用 VITE_ 前缀。
1610
+ # Agent profile 仅由服务端 Agent Runtime 读取;不要使用 VITE_ 前缀。
1610
1611
  PLATFORM_ENABLE_AI=false
1611
1612
  AGENT_SERVER_INTERNAL_TOKEN=
1612
1613
  QWEN_API_URL=
1613
1614
  QWEN_API_KEY=
1614
1615
  QWEN_MODEL=
1616
+ AI_PROVIDER_HTTPS_HOST_ALLOWLIST=
1615
1617
  # AI_PROVIDER_CONNECT_TIMEOUT_MS=5000
1616
1618
  # AI_PROVIDER_REQUEST_TIMEOUT_MS=60000
1617
1619
  # AI_PROVIDER_MAX_RESPONSE_BYTES=2097152
@@ -1712,7 +1714,9 @@ bun run dev:agent
1712
1714
  \`\`\`
1713
1715
 
1714
1716
  公网 Provider URL 必须使用 HTTPS;本机、容器服务名和内网地址允许 HTTP。
1715
- \`QWEN_API_KEY\` 只进入 Agent Server,不会进入 Web 环境或返回给客户端。运行与模块
1717
+ \`QWEN_API_KEY\`、Endpoint 和 Host allowlist 只进入 Agent Server,不会进入 Web
1718
+ 环境或返回给客户端。公网 HTTPS Endpoint 的主机必须显式加入
1719
+ \`AI_PROVIDER_HTTPS_HOST_ALLOWLIST\`。运行与模块
1716
1720
  接入细节见 [Agent Runtime 接入](./docs/platform/agent-runtime.md)。
1717
1721
 
1718
1722
  MCP 客户端使用 \`http://localhost:9747/api/mcp\`,并通过平台 Bearer Token 或 Session
@@ -1913,6 +1917,7 @@ var templateDirectories = [
1913
1917
  var templateDocumentationFiles = [
1914
1918
  "docs/adapters/ruoyi-vue-compatibility.md",
1915
1919
  "docs/architecture/admin-surface-boundary.md",
1920
+ "docs/architecture/ai-egress.md",
1916
1921
  "docs/architecture/ai-runtime.md",
1917
1922
  "docs/architecture/data-scope-query-enforcement.md",
1918
1923
  "docs/architecture/durable-jobs.md",
@@ -4355,7 +4360,9 @@ var recipes = [
4355
4360
  { id: "starter-0.2.26-to-0.2.27", from: "0.2.26", to: "0.2.27" },
4356
4361
  { id: "starter-0.2.27-to-0.2.28", from: "0.2.27", to: "0.2.28" },
4357
4362
  { id: "starter-0.2.28-to-0.2.29", from: "0.2.28", to: "0.2.29" },
4358
- { id: "starter-0.2.29-to-0.2.30", from: "0.2.29", to: "0.2.30" }
4363
+ { id: "starter-0.2.29-to-0.2.30", from: "0.2.29", to: "0.2.30" },
4364
+ { id: "starter-0.2.30-to-0.2.31", from: "0.2.30", to: "0.2.31" },
4365
+ { id: "starter-0.2.31-to-0.2.32", from: "0.2.31", to: "0.2.32" }
4359
4366
  ];
4360
4367
  function resolveRecipeChain(sourceVersion, targetVersion) {
4361
4368
  if (sourceVersion === targetVersion)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-windy",
3
- "version": "0.2.30",
3
+ "version": "0.2.32",
4
4
  "description": "创建单组织、单租户、私有部署优先的 Windy 企业 Dashboard Starter",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "templateCommit": "c61be0fc258ae679c0c0b43d7a838688985d7f0f",
4
- "generatorVersion": "0.2.30",
3
+ "templateCommit": "70938ba644c8fdebec29f8bf24439ccf141f2336",
4
+ "generatorVersion": "0.2.32",
5
5
  "modules": [
6
6
  {
7
7
  "name": "system",
@@ -30,12 +30,14 @@ export AGENT_SERVER_INTERNAL_TOKEN='<random-token>'
30
30
  export QWEN_API_URL='https://dashscope-intl.aliyuncs.com/compatible-mode/v1'
31
31
  export QWEN_API_KEY='<server-secret>'
32
32
  export QWEN_MODEL='qwen-plus'
33
+ export AI_PROVIDER_HTTPS_HOST_ALLOWLIST='dashscope-intl.aliyuncs.com'
33
34
  export AGENT_RUN_STORE_PATH='.data/agent-runs.sqlite3'
34
35
  uv run uvicorn southwind_agent_server.app:app
35
36
  ```
36
37
 
37
- 公网 URL 必须使用 HTTPS,本机、容器服务名和内网地址允许 HTTP。Provider 请求包含连接
38
- 和总超时、响应大小限制、错误脱敏与 `/models` readiness。缺少 URL/Model 时回退
38
+ 公网 URL 必须使用 HTTPS Host 精确命中 allowlist;本机、容器服务名和内网地址允许
39
+ HTTP。Provider 请求包含连接和总超时、响应大小限制、错误脱敏与 `/models`
40
+ readiness。缺少 URL/Model 时回退
39
41
  `AdkEngineAdapter` 并返回 unavailable,不调用模型。测试另使用
40
42
  `DeterministicEngineAdapter`。
41
43
 
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import ipaddress
4
4
  import os
5
+ import re
5
6
  from collections.abc import Mapping
6
7
  from dataclasses import dataclass
7
8
  from urllib.parse import urlsplit, urlunsplit
@@ -35,6 +36,13 @@ class OpenAiCompatibleConfig:
35
36
  request_timeout_seconds: float = 60.0
36
37
  max_response_bytes: int = 2 * 1024 * 1024
37
38
  max_tool_rounds: int = 8
39
+ https_host_allowlist: tuple[str, ...] = ()
40
+ deployment_key: str = "ai.openai-compatible"
41
+
42
+ def __post_init__(self) -> None:
43
+ normalized = normalize_base_url(self.base_url)
44
+ object.__setattr__(self, "base_url", normalized)
45
+ require_allowed_https_host(normalized, self.https_host_allowlist)
38
46
 
39
47
  @classmethod
40
48
  def from_environment(
@@ -55,6 +63,9 @@ class OpenAiCompatibleConfig:
55
63
  if environment.get("QWEN_API_KEY", "").strip()
56
64
  else None
57
65
  ),
66
+ https_host_allowlist=normalize_https_host_allowlist(
67
+ environment.get("AI_PROVIDER_HTTPS_HOST_ALLOWLIST", "")
68
+ ),
58
69
  connect_timeout_seconds=read_milliseconds(
59
70
  environment,
60
71
  "AI_PROVIDER_CONNECT_TIMEOUT_MS",
@@ -100,6 +111,43 @@ def normalize_base_url(raw: str) -> str:
100
111
  return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
101
112
 
102
113
 
114
+ def normalize_https_host_allowlist(raw: str) -> tuple[str, ...]:
115
+ hosts: list[str] = []
116
+ for item in raw.split(","):
117
+ candidate = item.strip().lower().rstrip(".")
118
+ if not candidate:
119
+ continue
120
+ if candidate in hosts or not valid_hostname(candidate):
121
+ raise ValueError("Provider HTTPS Host allowlist 无效")
122
+ hosts.append(candidate)
123
+ return tuple(hosts)
124
+
125
+
126
+ def require_allowed_https_host(
127
+ base_url: str,
128
+ allowed_hosts: tuple[str, ...],
129
+ ) -> None:
130
+ endpoint = urlsplit(base_url)
131
+ hostname = (endpoint.hostname or "").lower().rstrip(".")
132
+ if endpoint.scheme == "https" and hostname not in allowed_hosts:
133
+ raise ValueError("OpenAI-compatible Endpoint 未命中 HTTPS Host allowlist")
134
+
135
+
136
+ def valid_hostname(value: str) -> bool:
137
+ try:
138
+ ipaddress.ip_address(value)
139
+ return True
140
+ except ValueError:
141
+ return bool(
142
+ len(value) <= 253
143
+ and re.fullmatch(
144
+ r"(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*"
145
+ r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?",
146
+ value,
147
+ )
148
+ )
149
+
150
+
103
151
  def private_hostname(hostname: str) -> bool:
104
152
  if (
105
153
  hostname == "localhost"
@@ -1,8 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Literal
3
+ from typing import Literal, Self
4
4
 
5
- from pydantic import BaseModel, ConfigDict, Field
5
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
6
6
 
7
7
  type JsonValue = (
8
8
  str | int | float | bool | None | list[JsonValue] | dict[str, JsonValue]
@@ -60,6 +60,24 @@ class RuntimeLimits(CanonicalModel):
60
60
  max_output_bytes: int = Field(default=1_048_576, ge=1_024, le=16_777_216)
61
61
 
62
62
 
63
+ class ProviderEgress(CanonicalModel):
64
+ mode: Literal["forbidden", "configured-endpoint"] = "forbidden"
65
+ deployment_key: str | None = None
66
+
67
+ @model_validator(mode="after")
68
+ def validate_deployment(self) -> Self:
69
+ if self.mode == "configured-endpoint":
70
+ if not self.deployment_key or not self.deployment_key.strip():
71
+ raise ValueError("Provider Deployment Key 缺失")
72
+ elif self.deployment_key is not None:
73
+ raise ValueError("禁止出网时不能指定 Provider Deployment")
74
+ return self
75
+
76
+
77
+ class ToolNetworkPolicy(CanonicalModel):
78
+ mode: Literal["forbidden"] = "forbidden"
79
+
80
+
63
81
  class StartRunRequest(CanonicalModel):
64
82
  operation_key: str = Field(min_length=1)
65
83
  input: JsonValue
@@ -67,6 +85,8 @@ class StartRunRequest(CanonicalModel):
67
85
  user_prompt: str = ""
68
86
  allowed_tools: list[RuntimeToolDefinition] = Field(default_factory=list)
69
87
  limits: RuntimeLimits = Field(default_factory=RuntimeLimits)
88
+ provider_egress: ProviderEgress = Field(default_factory=ProviderEgress)
89
+ tool_network_policy: ToolNetworkPolicy = Field(default_factory=ToolNetworkPolicy)
70
90
  execution_grant: str = ""
71
91
  tool_host_url: str = ""
72
92
 
@@ -4,7 +4,14 @@ from collections.abc import AsyncIterator
4
4
  from dataclasses import dataclass, field
5
5
  from typing import Protocol
6
6
 
7
- from .contracts import AgentErrorCode, JsonValue, RuntimeLimits, RuntimeToolDefinition
7
+ from .contracts import (
8
+ AgentErrorCode,
9
+ JsonValue,
10
+ ProviderEgress,
11
+ RuntimeLimits,
12
+ RuntimeToolDefinition,
13
+ ToolNetworkPolicy,
14
+ )
8
15
 
9
16
 
10
17
  @dataclass(frozen=True)
@@ -16,6 +23,8 @@ class EngineRunRequest:
16
23
  user_prompt: str = ""
17
24
  allowed_tools: tuple[RuntimeToolDefinition, ...] = ()
18
25
  limits: RuntimeLimits = field(default_factory=RuntimeLimits)
26
+ provider_egress: ProviderEgress = field(default_factory=ProviderEgress)
27
+ tool_network_policy: ToolNetworkPolicy = field(default_factory=ToolNetworkPolicy)
19
28
  execution_grant: str = ""
20
29
  tool_host_url: str = ""
21
30
 
@@ -37,7 +37,7 @@ class OpenAiCompatibleEngineAdapter(AgentEngineAdapter):
37
37
  self,
38
38
  request: EngineRunRequest,
39
39
  ) -> AsyncIterator[EngineSignal]:
40
- validate_runtime_request(request)
40
+ validate_runtime_request(request, self._provider.config.deployment_key)
41
41
  cancellation = self._cancellations.setdefault(
42
42
  request.run_id,
43
43
  asyncio.Event(),
@@ -136,7 +136,10 @@ class OpenAiCompatibleEngineAdapter(AgentEngineAdapter):
136
136
  await self._provider.health()
137
137
 
138
138
 
139
- def validate_runtime_request(request: EngineRunRequest) -> None:
139
+ def validate_runtime_request(
140
+ request: EngineRunRequest,
141
+ provider_deployment_key: str,
142
+ ) -> None:
140
143
  if (
141
144
  not request.instructions.strip()
142
145
  or not request.user_prompt.strip()
@@ -148,6 +151,21 @@ def validate_runtime_request(request: EngineRunRequest) -> None:
148
151
  "Agent Operation 运行计划不完整",
149
152
  retryable=False,
150
153
  )
154
+ if (
155
+ request.provider_egress.mode != "configured-endpoint"
156
+ or request.provider_egress.deployment_key != provider_deployment_key
157
+ ):
158
+ raise SafeEngineError(
159
+ "FORBIDDEN",
160
+ "Agent Operation 未授权当前模型部署出网",
161
+ retryable=False,
162
+ )
163
+ if request.tool_network_policy.mode != "forbidden":
164
+ raise SafeEngineError(
165
+ "TOOL_DENIED",
166
+ "Agent Tool 网络策略不受支持",
167
+ retryable=False,
168
+ )
151
169
 
152
170
 
153
171
  def cancelled(event: asyncio.Event) -> None:
@@ -85,6 +85,8 @@ class AgentRunService:
85
85
  user_prompt=invocation.user_prompt,
86
86
  allowed_tools=tuple(invocation.allowed_tools),
87
87
  limits=invocation.limits,
88
+ provider_egress=invocation.provider_egress,
89
+ tool_network_policy=invocation.tool_network_policy,
88
90
  execution_grant=invocation.execution_grant,
89
91
  tool_host_url=invocation.tool_host_url,
90
92
  )
@@ -168,6 +168,34 @@ async def test_request_validation_uses_canonical_safe_error(
168
168
  assert "detail" not in response.json()
169
169
 
170
170
 
171
+ @pytest.mark.parametrize(
172
+ "network_fields",
173
+ [
174
+ {"providerEgress": {"mode": "configured-endpoint"}},
175
+ {"providerEgress": {"mode": "arbitrary-url"}},
176
+ {"toolNetworkPolicy": {"mode": "allowed"}},
177
+ {"providerUrl": "https://attacker.invalid/v1"},
178
+ ],
179
+ )
180
+ async def test_network_policy_dto_is_strict_and_fail_closed(
181
+ client: httpx.AsyncClient,
182
+ network_fields: dict[str, object],
183
+ ) -> None:
184
+ response = await client.post(
185
+ "/api/agent/runs",
186
+ headers={"Idempotency-Key": "network-policy"},
187
+ json={
188
+ "operationKey": "test.echo",
189
+ "input": None,
190
+ **network_fields,
191
+ },
192
+ )
193
+
194
+ assert response.status_code == 400
195
+ assert response.json()["code"] == "INVALID_INPUT"
196
+ assert "attacker.invalid" not in response.text
197
+
198
+
171
199
  async def test_internal_token_protects_run_api() -> None:
172
200
  protected = create_app(
173
201
  DeterministicEngineAdapter(),
@@ -14,10 +14,12 @@ def test_server_config_uses_secret_ref_and_normalizes_endpoint() -> None:
14
14
  "QWEN_API_URL": "https://example.com/compatible-mode/v1/",
15
15
  "QWEN_MODEL": "qwen-plus",
16
16
  "QWEN_API_KEY": "server-secret",
17
+ "AI_PROVIDER_HTTPS_HOST_ALLOWLIST": "example.com",
17
18
  }
18
19
  config = OpenAiCompatibleConfig.from_environment(environment)
19
20
  assert config is not None
20
21
  assert config.base_url == "https://example.com/compatible-mode/v1"
22
+ assert config.https_host_allowlist == ("example.com",)
21
23
  assert str(config.api_key_ref) == "[SecretRef]"
22
24
  resolver = EnvironmentSecretResolver(environment)
23
25
  assert config.api_key_ref is not None
@@ -47,6 +49,50 @@ def test_allows_private_http_endpoint() -> None:
47
49
  )
48
50
 
49
51
 
52
+ def test_https_endpoint_must_match_exact_host_allowlist() -> None:
53
+ with pytest.raises(ValueError) as captured:
54
+ OpenAiCompatibleConfig.from_environment(
55
+ {
56
+ "QWEN_API_URL": "https://api.example.com/v1",
57
+ "QWEN_MODEL": "qwen-plus",
58
+ "AI_PROVIDER_HTTPS_HOST_ALLOWLIST": "example.com",
59
+ }
60
+ )
61
+ assert "api.example.com" not in str(captured.value)
62
+
63
+ config = OpenAiCompatibleConfig.from_environment(
64
+ {
65
+ "QWEN_API_URL": "https://api.example.com/v1",
66
+ "QWEN_MODEL": "qwen-plus",
67
+ "AI_PROVIDER_HTTPS_HOST_ALLOWLIST": "api.example.com",
68
+ }
69
+ )
70
+ assert config is not None
71
+ assert config.https_host_allowlist == ("api.example.com",)
72
+
73
+
74
+ @pytest.mark.parametrize(
75
+ "allowlist",
76
+ [
77
+ "*.example.com",
78
+ "https://example.com",
79
+ "example.com:443",
80
+ "example.com,example.com",
81
+ ],
82
+ )
83
+ def test_rejects_unsafe_or_duplicate_https_host_allowlist(
84
+ allowlist: str,
85
+ ) -> None:
86
+ with pytest.raises(ValueError):
87
+ OpenAiCompatibleConfig.from_environment(
88
+ {
89
+ "QWEN_API_URL": "https://example.com/v1",
90
+ "QWEN_MODEL": "qwen-plus",
91
+ "AI_PROVIDER_HTTPS_HOST_ALLOWLIST": allowlist,
92
+ }
93
+ )
94
+
95
+
50
96
  def test_partial_deployment_fails_fast_without_leaking_value() -> None:
51
97
  marker = "DO-NOT-LEAK"
52
98
  with pytest.raises(ValueError) as captured:
@@ -10,7 +10,12 @@ from southwind_agent_server.config import (
10
10
  OpenAiCompatibleConfig,
11
11
  SecretRef,
12
12
  )
13
- from southwind_agent_server.contracts import RuntimeLimits, RuntimeToolDefinition
13
+ from southwind_agent_server.contracts import (
14
+ ProviderEgress,
15
+ RuntimeLimits,
16
+ RuntimeToolDefinition,
17
+ ToolNetworkPolicy,
18
+ )
14
19
  from southwind_agent_server.engine import (
15
20
  EngineRunRequest,
16
21
  SafeEngineError,
@@ -175,6 +180,55 @@ async def test_engine_denies_unknown_model_tool_before_host_callback() -> None:
175
180
  assert tool_called is False
176
181
 
177
182
 
183
+ async def test_engine_denies_provider_before_network_without_explicit_egress() -> None:
184
+ provider_called = False
185
+
186
+ async def provider_handler(_request: httpx.Request) -> httpx.Response:
187
+ nonlocal provider_called
188
+ provider_called = True
189
+ return sse_response([])
190
+
191
+ engine = OpenAiCompatibleEngineAdapter(
192
+ create_provider(httpx.MockTransport(provider_handler)),
193
+ ToolHostClient("internal-token"),
194
+ )
195
+ request = runtime_request("business.records.list")
196
+ request = EngineRunRequest(
197
+ **{
198
+ **request.__dict__,
199
+ "provider_egress": ProviderEgress(mode="forbidden"),
200
+ }
201
+ )
202
+
203
+ with pytest.raises(SafeEngineError) as captured:
204
+ async for _signal in engine.execute(request):
205
+ pass
206
+ assert captured.value.code == "FORBIDDEN"
207
+ assert provider_called is False
208
+
209
+
210
+ async def test_engine_denies_mismatched_provider_deployment() -> None:
211
+ engine = OpenAiCompatibleEngineAdapter(
212
+ create_provider(httpx.MockTransport(lambda _request: sse_response([]))),
213
+ ToolHostClient("internal-token"),
214
+ )
215
+ request = runtime_request("business.records.list")
216
+ request = EngineRunRequest(
217
+ **{
218
+ **request.__dict__,
219
+ "provider_egress": ProviderEgress(
220
+ mode="configured-endpoint",
221
+ deployment_key="ai.other-deployment",
222
+ ),
223
+ }
224
+ )
225
+
226
+ with pytest.raises(SafeEngineError) as captured:
227
+ async for _signal in engine.execute(request):
228
+ pass
229
+ assert captured.value.code == "FORBIDDEN"
230
+
231
+
178
232
  def create_provider(
179
233
  transport: httpx.AsyncBaseTransport,
180
234
  ) -> OpenAiCompatibleProvider:
@@ -184,6 +238,7 @@ def create_provider(
184
238
  base_url="https://provider.test/v1",
185
239
  model="qwen-compatible",
186
240
  api_key_ref=SecretRef("env", "QWEN_API_KEY"),
241
+ https_host_allowlist=("provider.test",),
187
242
  ),
188
243
  EnvironmentSecretResolver(environment),
189
244
  transport=transport,
@@ -208,6 +263,11 @@ def runtime_request(tool_key: str) -> EngineRunRequest:
208
263
  max_duration_seconds=60,
209
264
  max_output_bytes=4096,
210
265
  ),
266
+ provider_egress=ProviderEgress(
267
+ mode="configured-endpoint",
268
+ deployment_key="ai.openai-compatible",
269
+ ),
270
+ tool_network_policy=ToolNetworkPolicy(mode="forbidden"),
211
271
  execution_grant="grant_1",
212
272
  tool_host_url="http://server:9747",
213
273
  )
@@ -36,6 +36,11 @@ describe("RemoteAgentRuntime", () => {
36
36
  userPrompt: "你好",
37
37
  allowedTools: [],
38
38
  limits: manifestLimits,
39
+ providerEgress: {
40
+ mode: "configured-endpoint",
41
+ deploymentKey: "ai.openai-compatible",
42
+ },
43
+ toolNetworkPolicy: { mode: "forbidden" },
39
44
  executionGrant: "grant_1",
40
45
  };
41
46
  const runtime = new RemoteAgentRuntime({
@@ -51,9 +56,18 @@ describe("RemoteAgentRuntime", () => {
51
56
  maxDurationSeconds: 60,
52
57
  maxOutputBytes: 8_192,
53
58
  },
59
+ providerEgress: {
60
+ mode: "configured-endpoint",
61
+ deploymentKey: "ai.openai-compatible",
62
+ },
63
+ toolNetworkPolicy: { mode: "forbidden" },
54
64
  });
55
65
  expect(
56
66
  (received as { limits: Record<string, unknown> }).limits,
57
67
  ).not.toHaveProperty("maxInputBytes");
68
+ const serialized = JSON.stringify(received);
69
+ expect(serialized).not.toContain("QWEN_");
70
+ expect(serialized).not.toContain("apiKey");
71
+ expect(serialized).not.toContain("baseUrl");
58
72
  });
59
73
  });
@@ -37,6 +37,8 @@ export class RemoteAgentRuntime implements AgentRuntimePort {
37
37
  maxDurationSeconds: request.limits.maxDurationSeconds,
38
38
  maxOutputBytes: request.limits.maxOutputBytes,
39
39
  },
40
+ providerEgress: request.providerEgress,
41
+ toolNetworkPolicy: request.toolNetworkPolicy,
40
42
  executionGrant: request.executionGrant,
41
43
  toolHostUrl: this.options.toolHostUrl,
42
44
  };
@@ -1,3 +1,7 @@
1
+ import type {
2
+ AiProviderEgressPolicy,
3
+ AiToolNetworkPolicy,
4
+ } from "@southwind-ai/modules";
1
5
  import type { ModuleAiJsonValue } from "@southwind-ai/server-sdk";
2
6
 
3
7
  export interface AgentRuntimeOperation {
@@ -14,6 +18,8 @@ export interface AgentRuntimeOperation {
14
18
  maxDurationSeconds: number;
15
19
  maxOutputBytes: number;
16
20
  };
21
+ providerEgress: AiProviderEgressPolicy;
22
+ toolNetworkPolicy: AiToolNetworkPolicy;
17
23
  executionGrant: string;
18
24
  }
19
25
 
@@ -3,6 +3,10 @@ import type {
3
3
  ModuleAiOperationDefinition,
4
4
  ModuleManifest,
5
5
  } from "@southwind-ai/modules";
6
+ import {
7
+ resolveProviderEgress,
8
+ resolveToolNetworkPolicy,
9
+ } from "@southwind-ai/modules";
6
10
  import type {
7
11
  ModuleAiJsonValue,
8
12
  ModuleAiOperationHandlerRegistration,
@@ -105,6 +109,8 @@ export class AgentRunFacade {
105
109
  userPrompt: plan.userPrompt,
106
110
  allowedTools: operation.tools,
107
111
  limits: operation.definition.limits,
112
+ providerEgress: resolveProviderEgress(operation.definition),
113
+ toolNetworkPolicy: resolveToolNetworkPolicy(operation.definition),
108
114
  executionGrant: grant.token,
109
115
  },
110
116
  idempotencyKey,
@@ -77,6 +77,31 @@ describe("受限 Agent Tool Host", () => {
77
77
  expect(fixture.called).toEqual([]);
78
78
  });
79
79
 
80
+ test("Tool 不继承 Provider 出网权限且未知网络模式 fail closed", async () => {
81
+ const fixture = createFixture({
82
+ providerEgress: {
83
+ mode: "configured-endpoint",
84
+ deploymentKey: "ai.openai-compatible",
85
+ },
86
+ toolNetworkPolicy: { mode: "allow" },
87
+ } as unknown as Partial<ModuleAiOperationDefinition>);
88
+ const invocation = attachExecutionGrant(
89
+ {
90
+ runId: "run_1",
91
+ toolCallId: "call_1",
92
+ toolKey: "business.records.list",
93
+ arguments: {},
94
+ },
95
+ fixture.grant.token,
96
+ );
97
+
98
+ await expect(fixture.host.execute(invocation)).rejects.toMatchObject({
99
+ code: "TOOL_DENIED",
100
+ message: "Tool 网络策略不受当前 Host 支持",
101
+ });
102
+ expect(fixture.called).toEqual([]);
103
+ });
104
+
80
105
  test("使用宿主颁发 Actor 与 Scope 执行 Handler", async () => {
81
106
  const fixture = createFixture();
82
107
  const result = await fixture.host.execute(
@@ -1,4 +1,7 @@
1
- import type { ModuleManifest } from "@southwind-ai/modules";
1
+ import {
2
+ resolveToolNetworkPolicy,
3
+ type ModuleManifest,
4
+ } from "@southwind-ai/modules";
2
5
  import type {
3
6
  ModuleAgentToolHostPort,
4
7
  ModuleAgentToolInvocation,
@@ -30,6 +33,12 @@ export class RestrictedAgentToolHost implements ModuleAgentToolHostPort {
30
33
  readGrantToken(invocation),
31
34
  invocation.runId,
32
35
  );
36
+ if (resolveToolNetworkPolicy(grant.operation).mode !== "forbidden") {
37
+ throw new AgentToolHostError(
38
+ "TOOL_DENIED",
39
+ "Tool 网络策略不受当前 Host 支持",
40
+ );
41
+ }
33
42
  if (!grant.operation.allowedToolKeys.includes(invocation.toolKey)) {
34
43
  throw new AgentToolHostError("TOOL_DENIED", "Operation 未授权该 Tool");
35
44
  }
@@ -67,6 +67,7 @@ services:
67
67
  QWEN_API_URL: ${QWEN_API_URL:-}
68
68
  QWEN_API_KEY: ${QWEN_API_KEY:-}
69
69
  QWEN_MODEL: ${QWEN_MODEL:-}
70
+ AI_PROVIDER_HTTPS_HOST_ALLOWLIST: ${AI_PROVIDER_HTTPS_HOST_ALLOWLIST:-}
70
71
  AI_PROVIDER_CONNECT_TIMEOUT_MS: ${AI_PROVIDER_CONNECT_TIMEOUT_MS:-5000}
71
72
  AI_PROVIDER_REQUEST_TIMEOUT_MS: ${AI_PROVIDER_REQUEST_TIMEOUT_MS:-60000}
72
73
  AI_PROVIDER_MAX_RESPONSE_BYTES: ${AI_PROVIDER_MAX_RESPONSE_BYTES:-2097152}
@@ -0,0 +1,80 @@
1
+ # Agent Provider 与 Tool 网络边界
2
+
3
+ 更新时间:2026-07-25。
4
+
5
+ ## 目标与威胁
6
+
7
+ Agent Operation 需要把受控业务输入发送给独立模型 Provider,但模型提出的 Tool Call
8
+ 不能因此获得相同出网权限。旧 `publicNetworkAccess` 把两类边界合成一个布尔值,无法
9
+ 表达“允许 Qwen Provider、禁止业务 Tool 任意出网”,现仅作为兼容输入保留。
10
+
11
+ 攻击面包括客户端伪造网络策略、模型选择任意 Provider URL、Provider 配置指向未批准
12
+ Host、模型请求未知 Tool,以及 Tool 把 Provider 权限当作自身网络授权。Endpoint、
13
+ API Key、Actor、Scope 和授权事实均不得由浏览器或模型提供。
14
+
15
+ ## Manifest 契约
16
+
17
+ Operation 分别声明 Provider egress 与 Tool execution network policy:
18
+
19
+ ```ts
20
+ {
21
+ dataClassification: "internal",
22
+ providerEgress: {
23
+ mode: "configured-endpoint",
24
+ deploymentKey: "ai.openai-compatible",
25
+ },
26
+ toolNetworkPolicy: {
27
+ mode: "forbidden",
28
+ },
29
+ }
30
+ ```
31
+
32
+ - 未声明 `providerEgress` 时按 `forbidden` 处理;旧
33
+ `publicNetworkAccess: "forbidden"` 同样不会开启 Provider。
34
+ - 旧 `publicNetworkAccess: "allowed"` 无法安全迁移,组合校验会要求改为新契约。
35
+ - 当前 Provider 只支持 `configured-endpoint`:Operation 只选择稳定 Deployment Key,
36
+ 不能携带 URL、Host 或 Secret。
37
+ - 当前 Tool 网络策略只支持 `forbidden`,缺省也是 `forbidden`。Provider egress 不会
38
+ 继承给 Tool。
39
+
40
+ ## Provider 强制执行
41
+
42
+ OpenAI-compatible Adapter 只使用 Agent Server 启动配置的一个 Endpoint。公网 HTTPS
43
+ 配置还必须通过 `AI_PROVIDER_HTTPS_HOST_ALLOWLIST` 的精确 Host 匹配;不支持通配符、
44
+ scheme、port 或重复 Host。内网 HTTP 继续受既有私有地址校验约束。
45
+
46
+ Southwind AI Server 向 Agent Server 只发送 Operation 的 policy mode 与 Deployment
47
+ Key。Agent Server 在发出模型请求前核对:
48
+
49
+ 1. policy 必须是 `configured-endpoint`;
50
+ 2. Deployment Key 必须等于当前 Adapter 的配置 Key;
51
+ 3. 启动时已验证配置 Endpoint 的 HTTPS Host allowlist;
52
+ 4. URL 仍来自 Agent Server 配置,Key 仍由 `SecretRef` 最后时刻解析。
53
+
54
+ 浏览器 API、SSE、错误响应和 Web 环境均不包含 URL、Host allowlist 或 API Key。
55
+
56
+ ## Tool 强制执行
57
+
58
+ 短时 Execution Grant 保存宿主校验过的 Operation。每次 Tool 回调重新读取 Grant,并
59
+ 要求 Tool 属于 `allowedToolKeys`、当前 Guard 仍通过且 Tool 网络策略为 `forbidden`。
60
+ 客户端和模型不能在 Tool Invocation 中提供或覆盖网络策略。
61
+
62
+ 当前 Module Tool Context 不提供 HTTP/网络 Port,因此平台没有可由模型选择 URL 的通用
63
+ 出网 Adapter。若未来开放 Tool 出网,必须新增宿主实现的受限 Port,按 Adapter 和精确
64
+ 目的 Host 授权,并在 Manifest 组合、运行时和部署防火墙三层同时允许。
65
+
66
+ ## 尚未覆盖
67
+
68
+ 本 patch 完成契约拆分、Provider 精确配置绑定、HTTPS Host allowlist 和拒绝回归,但
69
+ 不把以下能力宣称为已完成:
70
+
71
+ - 现有 `dataClassification` 是 Operation 级分类,尚未提供字段级数据分类、发送字段
72
+ allowlist、脱敏或 DLP 检查;
73
+ - 现有 Run 与 Tool 审计不等于逐次 Provider 发送审计;尚未记录目标 Deployment、
74
+ 数据分类、字节数与策略决定的独立 append-only 事实;
75
+ - 同进程 project-owned Tool Handler 不是 OS 网络沙箱。平台契约不授予网络 Port,
76
+ 但无法拦截业务代码直接调用全局 `fetch`;生产 deny-egress 仍需容器网络策略或防火墙;
77
+ - DNS 解析钉扎、代理治理和多 Provider/多 Endpoint 动态路由尚未实现。
78
+
79
+ 在这些能力补齐前,只能声明“Provider 与 Tool 授权语义已隔离”,不能声明业务 Tool 已
80
+ 获得进程级强制断网或完整数据出境治理。
@@ -250,7 +250,8 @@ interface SpeechClient {
250
250
  - 所需模型 capability;
251
251
  - 交互方式:interactive、streaming、background 或 live;
252
252
  - 最大运行时间、输入大小和输出大小;
253
- - 是否允许向公网 Provider 发送数据。
253
+ - Provider egress policy 与稳定 Deployment Key;
254
+ - 独立的 Tool execution network policy。
254
255
 
255
256
  示例:
256
257
 
@@ -267,9 +268,20 @@ interface ModuleAiOperationDefinition {
267
268
  allowedToolKeys: readonly string[];
268
269
  dataClassification: "public" | "internal" | "sensitive";
269
270
  execution: "interactive" | "streaming" | "background" | "live";
271
+ providerEgress?: {
272
+ mode: "forbidden" | "configured-endpoint";
273
+ deploymentKey?: string;
274
+ };
275
+ toolNetworkPolicy?: {
276
+ mode: "forbidden";
277
+ };
270
278
  }
271
279
  ```
272
280
 
281
+ 两项缺省均 fail closed。Provider 的实际 Endpoint 与 HTTPS Host allowlist 只存在于
282
+ 服务端 Deployment 配置,不由业务 Manifest、浏览器或模型提供。详见
283
+ [Agent Provider 与 Tool 网络边界](./ai-egress.md)。
284
+
273
285
  真实 Operation Handler 与 Manifest 声明必须一一对应。重复、缺失、未知引用、命名空间不匹配和 capability digest 漂移都应在监听前 fail-fast。
274
286
 
275
287
  ## AI Deployment Registry
@@ -11,7 +11,8 @@ Grant 与审计;独立 Agent Server 只负责模型循环、canonical event
11
11
 
12
12
  - OpenAI-compatible `/chat/completions` Provider,支持流式文本和 function calling。
13
13
  - `QWEN_API_KEY` 使用服务端 `SecretRef`,只在 Provider Adapter 发请求前解析。
14
- - 公网 Endpoint 必须为 HTTPS;`localhost`、容器服务名、RFC 1918 地址和
14
+ - 公网 Endpoint 必须为 HTTPS,并精确命中
15
+ `AI_PROVIDER_HTTPS_HOST_ALLOWLIST`;`localhost`、容器服务名、RFC 1918 地址和
15
16
  `.internal`/`.local` 主机可使用 HTTP。URL 禁止内嵌凭据、query、fragment 和云
16
17
  metadata 地址。
17
18
  - Provider 有连接/请求超时、响应大小上限、安全错误映射和 `/models` readiness 检查。
@@ -29,6 +30,23 @@ Manifest 的 `aiOperations` 是声明事实。每个 Operation 必须声明自
29
30
  Permission、可选 Entitlement、输入/输出 schema 和 `allowedToolKeys`。模型只会收到该
30
31
  集合内的 Tool 定义。
31
32
 
33
+ Provider 与 Tool 网络策略必须分开声明。旧 `publicNetworkAccess` 不会开启 Provider:
34
+
35
+ ```ts
36
+ providerEgress: {
37
+ mode: "configured-endpoint",
38
+ deploymentKey: "ai.openai-compatible",
39
+ },
40
+ toolNetworkPolicy: {
41
+ mode: "forbidden",
42
+ },
43
+ ```
44
+
45
+ Provider URL 与 Secret 不在 Manifest 或浏览器 DTO 中;Agent Server 只允许当前启动
46
+ 配置的 Deployment。Tool policy 缺省为 forbidden,且不继承 Provider egress。完整
47
+ 威胁模型、兼容行为与尚未覆盖项见
48
+ [Agent Provider 与 Tool 网络边界](../architecture/ai-egress.md)。
49
+
32
50
  模块在 `setup(host)` 中注册模型无关的输入准备器:
33
51
 
34
52
  ```ts
@@ -85,6 +103,7 @@ AGENT_SERVER_INTERNAL_TOKEN=<至少 12 字符的随机值>
85
103
  QWEN_API_URL=https://dashscope-intl.aliyuncs.com/compatible-mode/v1
86
104
  QWEN_API_KEY=<服务端密钥>
87
105
  QWEN_MODEL=qwen-plus
106
+ AI_PROVIDER_HTTPS_HOST_ALLOWLIST=dashscope-intl.aliyuncs.com
88
107
 
89
108
  # 可选安全上限
90
109
  # AI_PROVIDER_CONNECT_TIMEOUT_MS=5000
@@ -181,6 +181,7 @@ test("OpenAI-compatible 部署只在服务端解析 Secret 与运行上限", ()
181
181
  AI_PROVIDER_CONNECT_TIMEOUT_MS: "2500",
182
182
  AI_PROVIDER_REQUEST_TIMEOUT_MS: "45000",
183
183
  AI_PROVIDER_MAX_RESPONSE_BYTES: "1048576",
184
+ AI_PROVIDER_HTTPS_HOST_ALLOWLIST: "workspace.cn-beijing.maas.aliyuncs.com",
184
185
  });
185
186
 
186
187
  expect(config).toEqual({
@@ -191,9 +192,28 @@ test("OpenAI-compatible 部署只在服务端解析 Secret 与运行上限", ()
191
192
  connectTimeoutMs: 2500,
192
193
  requestTimeoutMs: 45000,
193
194
  maxResponseBytes: 1048576,
195
+ allowedHttpsHosts: ["workspace.cn-beijing.maas.aliyuncs.com"],
194
196
  });
195
197
  });
196
198
 
199
+ test("OpenAI-compatible HTTPS Endpoint 必须命中精确 Host allowlist", () => {
200
+ expect(() =>
201
+ loadOpenAiCompatibleDeploymentConfig({
202
+ QWEN_API_URL: "https://api.example.com/v1",
203
+ QWEN_MODEL: "qwen-plus",
204
+ AI_PROVIDER_HTTPS_HOST_ALLOWLIST: "example.com",
205
+ }),
206
+ ).toThrow("allowedHttpsHosts");
207
+
208
+ expect(() =>
209
+ loadOpenAiCompatibleDeploymentConfig({
210
+ QWEN_API_URL: "https://api.example.com/v1",
211
+ QWEN_MODEL: "qwen-plus",
212
+ AI_PROVIDER_HTTPS_HOST_ALLOWLIST: "*.example.com",
213
+ }),
214
+ ).toThrow("allowedHttpsHosts");
215
+ });
216
+
197
217
  test("OpenAI-compatible Endpoint 拒绝公网 HTTP 与 metadata", () => {
198
218
  expect(() =>
199
219
  normalizeOpenAiCompatibleBaseUrl("http://api.example.com/v1"),
@@ -2,6 +2,7 @@ import { isIP } from "node:net";
2
2
  import { z } from "zod";
3
3
  import { defineModuleConfig } from "./definition.js";
4
4
  import {
5
+ commaSeparated,
5
6
  loadModuleConfigSync,
6
7
  strictInteger,
7
8
  type ConfigEnvironment,
@@ -15,10 +16,11 @@ export interface OpenAiCompatibleDeploymentConfig {
15
16
  connectTimeoutMs: number;
16
17
  requestTimeoutMs: number;
17
18
  maxResponseBytes: number;
19
+ allowedHttpsHosts: string[];
18
20
  }
19
21
 
20
- const deploymentSchema: z.ZodType<OpenAiCompatibleDeploymentConfig> =
21
- z.strictObject({
22
+ const deploymentSchema: z.ZodType<OpenAiCompatibleDeploymentConfig> = z
23
+ .strictObject({
22
24
  baseUrl: z.string().transform(normalizeOpenAiCompatibleBaseUrl),
23
25
  model: z.string().trim().min(1).max(200),
24
26
  apiKey: z.string().trim().min(1).optional(),
@@ -29,6 +31,20 @@ const deploymentSchema: z.ZodType<OpenAiCompatibleDeploymentConfig> =
29
31
  .int()
30
32
  .min(1_024)
31
33
  .max(16 * 1_024 * 1_024),
34
+ allowedHttpsHosts: z.array(z.string()).max(32),
35
+ })
36
+ .superRefine((config, context) => {
37
+ const endpoint = new URL(config.baseUrl);
38
+ if (
39
+ endpoint.protocol === "https:" &&
40
+ !config.allowedHttpsHosts.includes(endpoint.hostname.toLowerCase())
41
+ ) {
42
+ context.addIssue({
43
+ code: "custom",
44
+ path: ["allowedHttpsHosts"],
45
+ message: "Endpoint 未命中 HTTPS Host allowlist",
46
+ });
47
+ }
32
48
  });
33
49
 
34
50
  export const openAiCompatibleDeploymentConfigDefinition =
@@ -52,6 +68,12 @@ export const openAiCompatibleDeploymentConfigDefinition =
52
68
  "maxResponseBytes",
53
69
  2 * 1_024 * 1_024,
54
70
  ),
71
+ {
72
+ key: "AI_PROVIDER_HTTPS_HOST_ALLOWLIST",
73
+ path: "allowedHttpsHosts",
74
+ defaultValue: [],
75
+ decode: normalizeOpenAiCompatibleHttpsHostAllowlist,
76
+ },
55
77
  ],
56
78
  });
57
79
 
@@ -89,10 +111,44 @@ export function normalizeOpenAiCompatibleBaseUrl(raw: string): string {
89
111
  return url.toString().replace(/\/$/, "");
90
112
  }
91
113
 
114
+ export function normalizeOpenAiCompatibleHttpsHostAllowlist(
115
+ raw: string,
116
+ ): string[] {
117
+ const hosts = commaSeparated(raw).map((host) => normalizeHttpsHostname(host));
118
+ if (hosts.length > 32 || new Set(hosts).size !== hosts.length) {
119
+ throw new Error("Provider HTTPS Host allowlist 无效");
120
+ }
121
+ return hosts;
122
+ }
123
+
92
124
  function integer(key: string, path: string, defaultValue: number) {
93
125
  return { key, path, defaultValue, decode: strictInteger };
94
126
  }
95
127
 
128
+ function normalizeHttpsHostname(raw: string): string {
129
+ const hostname = raw.trim().toLowerCase().replace(/\.$/, "");
130
+ if (
131
+ !hostname ||
132
+ hostname.includes("*") ||
133
+ hostname.includes("/") ||
134
+ hostname.includes(":") ||
135
+ hostname.length > 253
136
+ ) {
137
+ throw new Error("Provider HTTPS Host allowlist 无效");
138
+ }
139
+ const labels = hostname.split(".");
140
+ if (
141
+ labels.some(
142
+ (label) =>
143
+ !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label) &&
144
+ !/^[a-z0-9]$/.test(label),
145
+ )
146
+ ) {
147
+ throw new Error("Provider HTTPS Host allowlist 无效");
148
+ }
149
+ return hostname;
150
+ }
151
+
96
152
  function isPrivateHost(hostname: string): boolean {
97
153
  if (
98
154
  hostname === "localhost" ||
@@ -7,3 +7,4 @@ export * from "./src/system-modules.js";
7
7
  export * from "./src/system-module-catalog.js";
8
8
  export * from "./src/system-api-permissions.js";
9
9
  export * from "./src/tool-composition.js";
10
+ export * from "./src/ai-network-policy.js";
@@ -0,0 +1,50 @@
1
+ import type {
2
+ AiProviderEgressPolicy,
3
+ AiToolNetworkPolicy,
4
+ ModuleAiOperationDefinition,
5
+ } from "./manifest.js";
6
+
7
+ const providerForbidden: AiProviderEgressPolicy = Object.freeze({
8
+ mode: "forbidden",
9
+ });
10
+ const toolNetworkForbidden: AiToolNetworkPolicy = Object.freeze({
11
+ mode: "forbidden",
12
+ });
13
+
14
+ export function resolveProviderEgress(
15
+ operation: ModuleAiOperationDefinition,
16
+ ): AiProviderEgressPolicy {
17
+ return operation.providerEgress ?? providerForbidden;
18
+ }
19
+
20
+ export function resolveToolNetworkPolicy(
21
+ operation: ModuleAiOperationDefinition,
22
+ ): AiToolNetworkPolicy {
23
+ return operation.toolNetworkPolicy ?? toolNetworkForbidden;
24
+ }
25
+
26
+ export function validateAiNetworkPolicy(
27
+ operation: ModuleAiOperationDefinition,
28
+ ): void {
29
+ const provider = resolveProviderEgress(operation);
30
+ if (provider.mode === "configured-endpoint") {
31
+ if (!provider.deploymentKey.trim()) {
32
+ throw new Error(`${operation.key} Provider Deployment Key 不能为空`);
33
+ }
34
+ } else if (provider.mode !== "forbidden") {
35
+ throw new Error(`${operation.key} Provider 出网策略无效`);
36
+ }
37
+
38
+ const tool = resolveToolNetworkPolicy(operation);
39
+ if (tool.mode !== "forbidden") {
40
+ throw new Error(`${operation.key} Tool 网络策略无效`);
41
+ }
42
+ if (
43
+ operation.publicNetworkAccess === "allowed" &&
44
+ operation.providerEgress === undefined
45
+ ) {
46
+ throw new Error(
47
+ `${operation.key} 旧 publicNetworkAccess=allowed 不能安全映射,请声明 providerEgress`,
48
+ );
49
+ }
50
+ }
@@ -98,6 +98,56 @@ describe("AI Operation Composition", () => {
98
98
  expect(() => validateModuleComposition([module])).toThrow("Tool 不能为空");
99
99
  });
100
100
 
101
+ test("Provider 与 Tool 网络边界独立且旧声明默认 fail closed", () => {
102
+ const module = fixture("business");
103
+ const legacy = operation("business.legacy");
104
+ module.aiOperations = [legacy];
105
+ expect(() => validateModuleComposition([module])).not.toThrow();
106
+
107
+ module.aiOperations = [
108
+ {
109
+ ...operation("business.generate"),
110
+ publicNetworkAccess: undefined,
111
+ providerEgress: {
112
+ mode: "configured-endpoint",
113
+ deploymentKey: "ai.openai-compatible",
114
+ },
115
+ toolNetworkPolicy: { mode: "forbidden" },
116
+ },
117
+ ];
118
+ expect(() => validateModuleComposition([module])).not.toThrow();
119
+
120
+ module.aiOperations[0] = {
121
+ ...operation("business.generate"),
122
+ publicNetworkAccess: "allowed",
123
+ };
124
+ expect(() => validateModuleComposition([module])).toThrow("不能安全映射");
125
+ });
126
+
127
+ test("拒绝空 Provider Deployment 与未知 Tool 网络模式", () => {
128
+ const module = fixture("business");
129
+ module.aiOperations = [
130
+ {
131
+ ...operation("business.generate"),
132
+ providerEgress: {
133
+ mode: "configured-endpoint",
134
+ deploymentKey: " ",
135
+ },
136
+ },
137
+ ];
138
+ expect(() => validateModuleComposition([module])).toThrow(
139
+ "Deployment Key 不能为空",
140
+ );
141
+
142
+ module.aiOperations[0] = {
143
+ ...operation("business.generate"),
144
+ toolNetworkPolicy: { mode: "allow" },
145
+ } as unknown as ModuleAiOperationDefinition;
146
+ expect(() => validateModuleComposition([module])).toThrow(
147
+ "Tool 网络策略无效",
148
+ );
149
+ });
150
+
101
151
  test("Operation Handler 必须与 Manifest 声明一一对应", () => {
102
152
  const module = fixture("business");
103
153
  module.aiOperations = [operation("business.generate")];
@@ -2,6 +2,7 @@ import type {
2
2
  ModuleAiOperationDefinition,
3
3
  ModuleManifest,
4
4
  } from "./manifest.js";
5
+ import { validateAiNetworkPolicy } from "./ai-network-policy.js";
5
6
 
6
7
  interface AiOperationCatalogs {
7
8
  permissions: ReadonlySet<string>;
@@ -100,6 +101,7 @@ function validateOperation(
100
101
  );
101
102
  validateCapabilities(operation);
102
103
  validateTools(operation, catalogs.tools);
104
+ validateAiNetworkPolicy(operation);
103
105
  validateLimits(operation);
104
106
  }
105
107
 
@@ -88,6 +88,15 @@ export type AiOperationExecution =
88
88
  | "background"
89
89
  | "live";
90
90
  export type AiOperationPublicNetworkAccess = "forbidden" | "allowed";
91
+ export type AiProviderEgressPolicy =
92
+ | { mode: "forbidden" }
93
+ | {
94
+ mode: "configured-endpoint";
95
+ deploymentKey: string;
96
+ };
97
+ export interface AiToolNetworkPolicy {
98
+ mode: "forbidden";
99
+ }
91
100
 
92
101
  export interface AiOperationLimits {
93
102
  maxDurationSeconds: number;
@@ -107,7 +116,10 @@ export interface ModuleAiOperationDefinition {
107
116
  allowedToolKeys: readonly string[];
108
117
  dataClassification: AiOperationDataClassification;
109
118
  execution: AiOperationExecution;
110
- publicNetworkAccess: AiOperationPublicNetworkAccess;
119
+ /** @deprecated 使用 providerEgress 与 toolNetworkPolicy 拆分两类网络边界。 */
120
+ publicNetworkAccess?: AiOperationPublicNetworkAccess;
121
+ providerEgress?: AiProviderEgressPolicy;
122
+ toolNetworkPolicy?: AiToolNetworkPolicy;
111
123
  limits: AiOperationLimits;
112
124
  }
113
125