create-windy 0.2.29 → 0.2.31

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 (45) hide show
  1. package/dist/cli.js +385 -9
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/README.md +3 -0
  5. package/template/apps/agent-server/README.md +4 -2
  6. package/template/apps/agent-server/src/southwind_agent_server/config.py +48 -0
  7. package/template/apps/agent-server/src/southwind_agent_server/contracts.py +22 -2
  8. package/template/apps/agent-server/src/southwind_agent_server/engine.py +10 -1
  9. package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +20 -2
  10. package/template/apps/agent-server/src/southwind_agent_server/service.py +2 -0
  11. package/template/apps/agent-server/tests/test_api.py +28 -0
  12. package/template/apps/agent-server/tests/test_config.py +46 -0
  13. package/template/apps/agent-server/tests/test_openai_provider.py +61 -1
  14. package/template/apps/server/src/agent/remote-runtime.test.ts +14 -0
  15. package/template/apps/server/src/agent/remote-runtime.ts +2 -0
  16. package/template/apps/server/src/agent/run-contracts.ts +6 -0
  17. package/template/apps/server/src/agent/run-facade.ts +6 -0
  18. package/template/apps/server/src/agent/tool-host.test.ts +25 -0
  19. package/template/apps/server/src/agent/tool-host.ts +10 -1
  20. package/template/apps/server/src/work-order/drizzle-repository.ts +1 -2
  21. package/template/apps/web/src/router/shared-scaffold-neutrality.webtest.ts +7 -5
  22. package/template/docker-compose.yml +1 -0
  23. package/template/docs/architecture/ai-egress.md +80 -0
  24. package/template/docs/architecture/ai-runtime.md +13 -1
  25. package/template/docs/architecture/migration-bundle.md +151 -0
  26. package/template/docs/platform/agent-runtime.md +20 -1
  27. package/template/module-schema.lineages.json +8 -0
  28. package/template/packages/config/index.test.ts +20 -0
  29. package/template/packages/config/src/ai-openai-compatible.ts +58 -2
  30. package/template/packages/database/drizzle/0033_rich_george_stacy.sql +2 -0
  31. package/template/packages/database/drizzle/meta/0033_snapshot.json +7239 -0
  32. package/template/packages/database/drizzle/meta/_journal.json +7 -0
  33. package/template/packages/database/src/schema/index.ts +0 -3
  34. package/template/packages/example-work-order/drizzle/0000_bitter_the_santerians.sql +2 -0
  35. package/template/packages/example-work-order/drizzle/meta/0000_snapshot.json +179 -0
  36. package/template/packages/example-work-order/drizzle/meta/_journal.json +13 -0
  37. package/template/packages/example-work-order/drizzle.config.ts +8 -0
  38. package/template/packages/example-work-order/index.ts +1 -0
  39. package/template/packages/example-work-order/package.json +4 -0
  40. package/template/packages/{database/src/schema/work-orders.ts → example-work-order/src/drizzle-schema.ts} +16 -2
  41. package/template/packages/modules/index.ts +1 -0
  42. package/template/packages/modules/src/ai-network-policy.ts +50 -0
  43. package/template/packages/modules/src/ai-operation-composition.test.ts +50 -0
  44. package/template/packages/modules/src/ai-operation-composition.ts +2 -0
  45. package/template/packages/modules/src/manifest.ts +13 -1
@@ -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
  }
@@ -1,5 +1,4 @@
1
- import { workOrders } from "@southwind-ai/database";
2
- import type { WorkOrder } from "@southwind-ai/example-work-order";
1
+ import { workOrders, type WorkOrder } from "@southwind-ai/example-work-order";
3
2
  import type { NodePgDatabase } from "drizzle-orm/node-postgres";
4
3
  import {
5
4
  DrizzleScopedEntityRepository,
@@ -5,16 +5,18 @@ import fixtureSource from "./router-test-fixtures.ts?raw";
5
5
  import { collectRouteAccess } from "./router-test-fixtures";
6
6
 
7
7
  describe("Router shared-scaffold 中性契约", () => {
8
- test("平台登录页与测试快照不固化下游产品命名", () => {
8
+ test("平台登录页使用中性共享页面", () => {
9
9
  expect(routerSource).toContain(
10
10
  'import LoginPage from "@/pages/auth/LoginPage.vue";',
11
11
  );
12
- expect(`${routerSource}\n${fixtureSource}`).not.toMatch(
13
- /LijianLoginPage|lijian\./,
14
- );
12
+ expect(fixtureSource).toContain("collectRouteAccess");
15
13
  });
16
14
 
17
- test("测试快照从项目路由递归派生真实访问键", () => {
15
+ test("测试快照只从项目注入路由递归派生访问键", () => {
16
+ expect(collectRouteAccess([])).toEqual({
17
+ permissionKeys: [],
18
+ featureKeys: [],
19
+ });
18
20
  const projectRoutes: RouteRecordRaw[] = [
19
21
  {
20
22
  path: "/records",
@@ -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