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.
- package/dist/cli.js +11 -4
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/apps/agent-server/README.md +4 -2
- package/template/apps/agent-server/src/southwind_agent_server/config.py +48 -0
- package/template/apps/agent-server/src/southwind_agent_server/contracts.py +22 -2
- package/template/apps/agent-server/src/southwind_agent_server/engine.py +10 -1
- package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +20 -2
- package/template/apps/agent-server/src/southwind_agent_server/service.py +2 -0
- package/template/apps/agent-server/tests/test_api.py +28 -0
- package/template/apps/agent-server/tests/test_config.py +46 -0
- package/template/apps/agent-server/tests/test_openai_provider.py +61 -1
- package/template/apps/server/src/agent/remote-runtime.test.ts +14 -0
- package/template/apps/server/src/agent/remote-runtime.ts +2 -0
- package/template/apps/server/src/agent/run-contracts.ts +6 -0
- package/template/apps/server/src/agent/run-facade.ts +6 -0
- package/template/apps/server/src/agent/tool-host.test.ts +25 -0
- package/template/apps/server/src/agent/tool-host.ts +10 -1
- package/template/docker-compose.yml +1 -0
- package/template/docs/architecture/ai-egress.md +80 -0
- package/template/docs/architecture/ai-runtime.md +13 -1
- package/template/docs/platform/agent-runtime.md +20 -1
- package/template/packages/config/index.test.ts +20 -0
- package/template/packages/config/src/ai-openai-compatible.ts +58 -2
- package/template/packages/modules/index.ts +1 -0
- package/template/packages/modules/src/ai-network-policy.ts +50 -0
- package/template/packages/modules/src/ai-operation-composition.test.ts +50 -0
- package/template/packages/modules/src/ai-operation-composition.ts +2 -0
- 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
|
-
`)
|
|
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
|
|
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
|
|
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
|
@@ -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
|
|
38
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
-
|
|
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" ||
|
|
@@ -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
|
-
|
|
119
|
+
/** @deprecated 使用 providerEgress 与 toolNetworkPolicy 拆分两类网络边界。 */
|
|
120
|
+
publicNetworkAccess?: AiOperationPublicNetworkAccess;
|
|
121
|
+
providerEgress?: AiProviderEgressPolicy;
|
|
122
|
+
toolNetworkPolicy?: AiToolNetworkPolicy;
|
|
111
123
|
limits: AiOperationLimits;
|
|
112
124
|
}
|
|
113
125
|
|