create-windy 0.2.33 → 0.2.34
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 +7 -1
- package/package.json +1 -1
- package/template/.agents/skills/windy-business-module/SKILL.md +10 -1
- package/template/.windy-template.json +2 -2
- package/template/AGENTS.md +4 -0
- package/template/apps/agent-server/README.md +10 -0
- package/template/apps/agent-server/src/southwind_agent_server/app.py +62 -1
- package/template/apps/agent-server/src/southwind_agent_server/contracts.py +35 -0
- package/template/apps/agent-server/src/southwind_agent_server/engine.py +2 -0
- package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +31 -3
- package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +22 -9
- package/template/apps/agent-server/src/southwind_agent_server/provider_audit.py +144 -0
- package/template/apps/agent-server/src/southwind_agent_server/provider_governance.py +227 -0
- package/template/apps/agent-server/src/southwind_agent_server/service.py +1 -0
- package/template/apps/agent-server/tests/test_api.py +16 -1
- package/template/apps/agent-server/tests/test_openai_provider.py +79 -0
- package/template/apps/agent-server/tests/test_provider_governance.py +215 -0
- package/template/apps/server/src/agent/provider-input-governance.test.ts +82 -0
- package/template/apps/server/src/agent/provider-input-governance.ts +140 -0
- package/template/apps/server/src/agent/remote-runtime.test.ts +22 -0
- package/template/apps/server/src/agent/remote-runtime.ts +1 -0
- package/template/apps/server/src/agent/run-contracts.ts +2 -0
- package/template/apps/server/src/agent/run-facade.test.ts +52 -2
- package/template/apps/server/src/agent/run-facade.ts +36 -2
- package/template/apps/server/src/index.ts +28 -45
- package/template/apps/server/src/module-host/initialize-contracts.ts +6 -0
- package/template/apps/server/src/module-host/initialize.ts +30 -44
- package/template/apps/server/src/module-host/request-context.ts +23 -17
- package/template/apps/server/src/module-host/resource-file-policies.test.ts +197 -0
- package/template/apps/server/src/module-host/resource-file-policies.ts +108 -0
- package/template/apps/server/src/module-host/route-declaration.ts +45 -0
- package/template/apps/server/src/module-host/route-installer.ts +27 -3
- package/template/apps/server/src/module-host/storage-port.test.ts +170 -1
- package/template/apps/server/src/module-host/storage-port.ts +134 -1
- package/template/apps/server/src/module-host/storage-scope.ts +96 -0
- package/template/apps/server/src/module-host/tool-handlers.test.ts +305 -0
- package/template/apps/server/src/module-host/tool-handlers.ts +38 -4
- package/template/apps/server/src/module-host/user-directory-composition.test.ts +126 -0
- package/template/apps/server/src/module-host/user-directory-port.test.ts +238 -0
- package/template/apps/server/src/module-host/user-directory-port.ts +143 -0
- package/template/apps/server/src/module-host.ts +4 -0
- package/template/apps/server/src/runtime-development.ts +1 -0
- package/template/apps/server/src/system/built-in-roles.ts +1 -0
- package/template/docker-compose.yml +2 -0
- package/template/docs/architecture/ai-egress.md +49 -7
- package/template/docs/architecture/ai-runtime.md +20 -1
- package/template/docs/architecture/data-scope-query-enforcement.md +4 -0
- package/template/docs/architecture/object-storage.md +22 -2
- package/template/docs/development/custom-login-page.md +3 -3
- package/template/docs/platform/agent-runtime.md +44 -1
- package/template/docs/platform/business-user-directory.md +82 -0
- package/template/packages/modules/package.json +1 -1
- package/template/packages/modules/src/ai-operation-composition.test.ts +68 -0
- package/template/packages/modules/src/ai-operation-composition.ts +115 -0
- package/template/packages/modules/src/manifest.ts +26 -0
- package/template/packages/modules/src/provider-data-policy-composition.test.ts +74 -0
- package/template/packages/modules/src/system-audit-actions.ts +5 -0
- package/template/packages/modules/src/system-features.ts +5 -0
- package/template/packages/modules/src/system-module-catalog.test.ts +16 -0
- package/template/packages/modules/src/system-module-catalog.ts +26 -3
- package/template/packages/modules/src/system-permissions.ts +2 -0
- package/template/packages/server-sdk/index.ts +11 -0
- package/template/packages/server-sdk/package.json +3 -2
- package/template/packages/server-sdk/src/ai-operation-registration.ts +2 -1
- package/template/packages/server-sdk/src/module-host.ts +8 -0
- package/template/packages/server-sdk/src/request-context.ts +2 -0
- package/template/packages/server-sdk/src/storage-port.ts +66 -0
- package/template/packages/server-sdk/src/tool-registration.ts +6 -0
- package/template/packages/server-sdk/src/user-directory-port.ts +42 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from .contracts import (
|
|
10
|
+
JsonValue,
|
|
11
|
+
ProviderDataField,
|
|
12
|
+
ProviderDataPolicy,
|
|
13
|
+
RuntimeToolDefinition,
|
|
14
|
+
)
|
|
15
|
+
from .engine import SafeEngineError
|
|
16
|
+
from .provider_audit import ProviderAttemptAudit, ProviderAuditStore
|
|
17
|
+
|
|
18
|
+
_DLP_PATTERNS = (
|
|
19
|
+
re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]{8,}"),
|
|
20
|
+
re.compile(r"(?i)\b(?:api[_-]?key|secret|password|token)\s*[:=]\s*\S+"),
|
|
21
|
+
re.compile(r"\b1[3-9]\d{9}\b"),
|
|
22
|
+
re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.I),
|
|
23
|
+
re.compile(r"\b\d{17}[\dXx]\b"),
|
|
24
|
+
re.compile(r"\b(?:\d[ -]?){16,30}\b"),
|
|
25
|
+
)
|
|
26
|
+
_REDACTED = "[REDACTED]"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class GovernedAttempt:
|
|
31
|
+
messages: list[dict[str, JsonValue]]
|
|
32
|
+
tools: tuple[RuntimeToolDefinition, ...]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ProviderRequestGovernance:
|
|
36
|
+
def __init__(self, audit: ProviderAuditStore) -> None:
|
|
37
|
+
self.audit = audit
|
|
38
|
+
|
|
39
|
+
def authorize(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
deployment_key: str,
|
|
43
|
+
operation_key: str,
|
|
44
|
+
run_id: str,
|
|
45
|
+
attempt: int,
|
|
46
|
+
messages: list[dict[str, JsonValue]],
|
|
47
|
+
tools: tuple[RuntimeToolDefinition, ...],
|
|
48
|
+
policy: ProviderDataPolicy,
|
|
49
|
+
input_value: JsonValue = None,
|
|
50
|
+
measure: Callable[
|
|
51
|
+
[list[dict[str, JsonValue]], tuple[RuntimeToolDefinition, ...]], int
|
|
52
|
+
]
|
|
53
|
+
| None = None,
|
|
54
|
+
) -> GovernedAttempt:
|
|
55
|
+
rules = {rule.field: rule for rule in policy.envelope_fields}
|
|
56
|
+
classified: set[str] = set()
|
|
57
|
+
classified.update(input_classifications(input_value, policy))
|
|
58
|
+
redacted = False
|
|
59
|
+
governed_messages: list[dict[str, JsonValue]] = []
|
|
60
|
+
try:
|
|
61
|
+
for message in messages:
|
|
62
|
+
field = field_for_message(message)
|
|
63
|
+
rule = rules.get(field)
|
|
64
|
+
if rule is None:
|
|
65
|
+
raise GovernanceDenied(f"Provider 未授权发送字段:{field}")
|
|
66
|
+
classified.add(rule.classification)
|
|
67
|
+
value, changed = govern_value(message, rule.dlp_action)
|
|
68
|
+
redacted = redacted or changed
|
|
69
|
+
governed_messages.append(cast(dict[str, JsonValue], value))
|
|
70
|
+
governed_tools = tools
|
|
71
|
+
if tools:
|
|
72
|
+
rule = rules.get("tool-definitions")
|
|
73
|
+
if rule is None:
|
|
74
|
+
raise GovernanceDenied("Provider 未授权发送字段:tool-definitions")
|
|
75
|
+
classified.add(rule.classification)
|
|
76
|
+
raw_tools = [tool.model_dump(by_alias=True) for tool in tools]
|
|
77
|
+
governed, changed = govern_value(raw_tools, rule.dlp_action)
|
|
78
|
+
redacted = redacted or changed
|
|
79
|
+
governed_tools = tuple(
|
|
80
|
+
RuntimeToolDefinition.model_validate(item)
|
|
81
|
+
for item in cast(list[object], governed)
|
|
82
|
+
)
|
|
83
|
+
sent_bytes = (measure or payload_size)(
|
|
84
|
+
governed_messages,
|
|
85
|
+
governed_tools,
|
|
86
|
+
)
|
|
87
|
+
decision = "redact" if redacted else "allow"
|
|
88
|
+
self._record(
|
|
89
|
+
deployment_key,
|
|
90
|
+
operation_key,
|
|
91
|
+
run_id,
|
|
92
|
+
attempt,
|
|
93
|
+
classified,
|
|
94
|
+
sent_bytes,
|
|
95
|
+
decision,
|
|
96
|
+
)
|
|
97
|
+
return GovernedAttempt(governed_messages, governed_tools)
|
|
98
|
+
except GovernanceDenied:
|
|
99
|
+
self._record(
|
|
100
|
+
deployment_key,
|
|
101
|
+
operation_key,
|
|
102
|
+
run_id,
|
|
103
|
+
attempt,
|
|
104
|
+
classified,
|
|
105
|
+
0,
|
|
106
|
+
"reject",
|
|
107
|
+
)
|
|
108
|
+
raise SafeEngineError(
|
|
109
|
+
"FORBIDDEN",
|
|
110
|
+
"Provider 请求被字段治理策略拒绝",
|
|
111
|
+
retryable=False,
|
|
112
|
+
) from None
|
|
113
|
+
|
|
114
|
+
def health(self) -> dict[str, int | float | str | None]:
|
|
115
|
+
return self.audit.health()
|
|
116
|
+
|
|
117
|
+
def _record(
|
|
118
|
+
self,
|
|
119
|
+
deployment_key: str,
|
|
120
|
+
operation_key: str,
|
|
121
|
+
run_id: str,
|
|
122
|
+
attempt: int,
|
|
123
|
+
classified: set[str],
|
|
124
|
+
sent_bytes: int,
|
|
125
|
+
decision: str,
|
|
126
|
+
) -> None:
|
|
127
|
+
try:
|
|
128
|
+
self.audit.append(
|
|
129
|
+
ProviderAttemptAudit(
|
|
130
|
+
deployment_key,
|
|
131
|
+
operation_key,
|
|
132
|
+
run_id,
|
|
133
|
+
attempt,
|
|
134
|
+
tuple(sorted(classified)),
|
|
135
|
+
sent_bytes,
|
|
136
|
+
decision,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
except Exception:
|
|
140
|
+
raise SafeEngineError(
|
|
141
|
+
"FORBIDDEN",
|
|
142
|
+
"Provider 发送审计不可用",
|
|
143
|
+
retryable=False,
|
|
144
|
+
) from None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class GovernanceDenied(Exception):
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def field_for_message(message: dict[str, JsonValue]) -> ProviderDataField:
|
|
152
|
+
role = message.get("role")
|
|
153
|
+
if role == "system":
|
|
154
|
+
return "instructions"
|
|
155
|
+
if role == "user":
|
|
156
|
+
return "user-prompt"
|
|
157
|
+
if role == "assistant":
|
|
158
|
+
return "provider-output"
|
|
159
|
+
if role == "tool":
|
|
160
|
+
return "tool-results"
|
|
161
|
+
raise GovernanceDenied("Provider 消息角色未知")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def input_classifications(
|
|
165
|
+
value: JsonValue,
|
|
166
|
+
policy: ProviderDataPolicy,
|
|
167
|
+
) -> set[str]:
|
|
168
|
+
result: set[str] = set()
|
|
169
|
+
for rule in policy.input_fields:
|
|
170
|
+
current = value
|
|
171
|
+
found = True
|
|
172
|
+
for encoded in rule.json_pointer.removeprefix("/").split("/"):
|
|
173
|
+
segment = encoded.replace("~1", "/").replace("~0", "~")
|
|
174
|
+
if not isinstance(current, dict) or segment not in current:
|
|
175
|
+
found = False
|
|
176
|
+
break
|
|
177
|
+
current = current[segment]
|
|
178
|
+
if found:
|
|
179
|
+
result.add(rule.classification)
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def govern_value(value: object, action: str) -> tuple[object, bool]:
|
|
184
|
+
if isinstance(value, str):
|
|
185
|
+
detected = any(pattern.search(value) for pattern in _DLP_PATTERNS)
|
|
186
|
+
if detected and action == "reject":
|
|
187
|
+
raise GovernanceDenied("DLP reject")
|
|
188
|
+
if detected:
|
|
189
|
+
result = value
|
|
190
|
+
for pattern in _DLP_PATTERNS:
|
|
191
|
+
result = pattern.sub(_REDACTED, result)
|
|
192
|
+
return result, result != value
|
|
193
|
+
return value, False
|
|
194
|
+
if isinstance(value, list):
|
|
195
|
+
changed = False
|
|
196
|
+
items: list[object] = []
|
|
197
|
+
for item in value:
|
|
198
|
+
governed, item_changed = govern_value(item, action)
|
|
199
|
+
items.append(governed)
|
|
200
|
+
changed = changed or item_changed
|
|
201
|
+
return items, changed
|
|
202
|
+
if isinstance(value, dict):
|
|
203
|
+
changed = False
|
|
204
|
+
governed_record: dict[str, object] = {}
|
|
205
|
+
for key, item in value.items():
|
|
206
|
+
governed, item_changed = govern_value(item, action)
|
|
207
|
+
governed_record[key] = governed
|
|
208
|
+
changed = changed or item_changed
|
|
209
|
+
return governed_record, changed
|
|
210
|
+
return value, False
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def payload_size(
|
|
214
|
+
messages: list[dict[str, JsonValue]],
|
|
215
|
+
tools: tuple[RuntimeToolDefinition, ...],
|
|
216
|
+
) -> int:
|
|
217
|
+
payload = {
|
|
218
|
+
"messages": messages,
|
|
219
|
+
"tools": [tool.model_dump(by_alias=True) for tool in tools],
|
|
220
|
+
}
|
|
221
|
+
return len(
|
|
222
|
+
json.dumps(
|
|
223
|
+
payload,
|
|
224
|
+
ensure_ascii=False,
|
|
225
|
+
separators=(",", ":"),
|
|
226
|
+
).encode()
|
|
227
|
+
)
|
|
@@ -86,6 +86,7 @@ class AgentRunService:
|
|
|
86
86
|
allowed_tools=tuple(invocation.allowed_tools),
|
|
87
87
|
limits=invocation.limits,
|
|
88
88
|
provider_egress=invocation.provider_egress,
|
|
89
|
+
provider_data_policy=invocation.provider_data_policy,
|
|
89
90
|
tool_network_policy=invocation.tool_network_policy,
|
|
90
91
|
execution_grant=invocation.execution_grant,
|
|
91
92
|
tool_host_url=invocation.tool_host_url,
|
|
@@ -9,7 +9,7 @@ import httpx
|
|
|
9
9
|
import pytest
|
|
10
10
|
from fastapi import FastAPI
|
|
11
11
|
|
|
12
|
-
from southwind_agent_server.app import create_app
|
|
12
|
+
from southwind_agent_server.app import create_app, provider_governance_snapshot
|
|
13
13
|
from southwind_agent_server.deterministic import DeterministicEngineAdapter
|
|
14
14
|
|
|
15
15
|
|
|
@@ -233,3 +233,18 @@ async def test_liveness_does_not_claim_provider_readiness(
|
|
|
233
233
|
assert live.json() == {"status": "live"}
|
|
234
234
|
assert ready.status_code == 503
|
|
235
235
|
assert ready.json()["code"] == "RUNTIME_UNAVAILABLE"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_provider_governance_health_filters_content_identifiers() -> None:
|
|
239
|
+
snapshot = provider_governance_snapshot(
|
|
240
|
+
{
|
|
241
|
+
"status": "healthy",
|
|
242
|
+
"retainedRecords": 3,
|
|
243
|
+
"runId": "run_secret",
|
|
244
|
+
"prompt": "do-not-expose",
|
|
245
|
+
}
|
|
246
|
+
)
|
|
247
|
+
assert snapshot["status"] == "healthy"
|
|
248
|
+
assert snapshot["retainedRecords"] == 3
|
|
249
|
+
assert "runId" not in snapshot
|
|
250
|
+
assert "prompt" not in snapshot
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import tempfile
|
|
4
6
|
|
|
5
7
|
import httpx
|
|
6
8
|
import pytest
|
|
@@ -11,7 +13,10 @@ from southwind_agent_server.config import (
|
|
|
11
13
|
SecretRef,
|
|
12
14
|
)
|
|
13
15
|
from southwind_agent_server.contracts import (
|
|
16
|
+
ProviderDataPolicy,
|
|
14
17
|
ProviderEgress,
|
|
18
|
+
ProviderFieldPolicy,
|
|
19
|
+
ProviderInputFieldPolicy,
|
|
15
20
|
RuntimeLimits,
|
|
16
21
|
RuntimeToolDefinition,
|
|
17
22
|
ToolNetworkPolicy,
|
|
@@ -24,6 +29,8 @@ from southwind_agent_server.engine import (
|
|
|
24
29
|
)
|
|
25
30
|
from southwind_agent_server.openai_engine import OpenAiCompatibleEngineAdapter
|
|
26
31
|
from southwind_agent_server.openai_provider import OpenAiCompatibleProvider
|
|
32
|
+
from southwind_agent_server.provider_audit import ProviderAuditStore
|
|
33
|
+
from southwind_agent_server.provider_governance import ProviderRequestGovernance
|
|
27
34
|
from southwind_agent_server.tool_host import ToolHostClient
|
|
28
35
|
|
|
29
36
|
|
|
@@ -76,11 +83,13 @@ async def test_provider_error_never_leaks_body_or_secret() -> None:
|
|
|
76
83
|
|
|
77
84
|
async def test_engine_executes_only_allowed_tool_through_host() -> None:
|
|
78
85
|
provider_calls = 0
|
|
86
|
+
provider_body_bytes: list[int] = []
|
|
79
87
|
tool_requests: list[dict[str, object]] = []
|
|
80
88
|
|
|
81
89
|
async def provider_handler(request: httpx.Request) -> httpx.Response:
|
|
82
90
|
nonlocal provider_calls
|
|
83
91
|
provider_calls += 1
|
|
92
|
+
provider_body_bytes.append(len(request.content))
|
|
84
93
|
if provider_calls == 1:
|
|
85
94
|
return sse_response(
|
|
86
95
|
[
|
|
@@ -111,12 +120,14 @@ async def test_engine_executes_only_allowed_tool_through_host() -> None:
|
|
|
111
120
|
tool_requests.append(json.loads(request.content))
|
|
112
121
|
return httpx.Response(200, json={"value": {"total": 1}})
|
|
113
122
|
|
|
123
|
+
audit_path = tempfile.NamedTemporaryFile(suffix=".sqlite3", delete=False).name
|
|
114
124
|
engine = OpenAiCompatibleEngineAdapter(
|
|
115
125
|
create_provider(httpx.MockTransport(provider_handler)),
|
|
116
126
|
ToolHostClient(
|
|
117
127
|
"internal-token",
|
|
118
128
|
transport=httpx.MockTransport(tool_handler),
|
|
119
129
|
),
|
|
130
|
+
governance(audit_path),
|
|
120
131
|
)
|
|
121
132
|
signals = [
|
|
122
133
|
signal
|
|
@@ -132,6 +143,20 @@ async def test_engine_executes_only_allowed_tool_through_host() -> None:
|
|
|
132
143
|
"arguments": {"page": 2},
|
|
133
144
|
}
|
|
134
145
|
assert signals[-1].output == {"text": "共有一条记录"} # type: ignore[union-attr]
|
|
146
|
+
audits = (
|
|
147
|
+
sqlite3.connect(audit_path)
|
|
148
|
+
.execute(
|
|
149
|
+
"""
|
|
150
|
+
select attempt, sent_bytes, decision
|
|
151
|
+
from provider_attempt_audit order by attempt
|
|
152
|
+
"""
|
|
153
|
+
)
|
|
154
|
+
.fetchall()
|
|
155
|
+
)
|
|
156
|
+
assert audits == [
|
|
157
|
+
(1, provider_body_bytes[0], "allow"),
|
|
158
|
+
(2, provider_body_bytes[1], "allow"),
|
|
159
|
+
]
|
|
135
160
|
|
|
136
161
|
|
|
137
162
|
async def test_engine_denies_unknown_model_tool_before_host_callback() -> None:
|
|
@@ -172,6 +197,7 @@ async def test_engine_denies_unknown_model_tool_before_host_callback() -> None:
|
|
|
172
197
|
"internal-token",
|
|
173
198
|
transport=httpx.MockTransport(tool_handler),
|
|
174
199
|
),
|
|
200
|
+
governance(),
|
|
175
201
|
)
|
|
176
202
|
with pytest.raises(SafeEngineError) as captured:
|
|
177
203
|
async for _signal in engine.execute(runtime_request("business.records.list")):
|
|
@@ -191,6 +217,7 @@ async def test_engine_denies_provider_before_network_without_explicit_egress() -
|
|
|
191
217
|
engine = OpenAiCompatibleEngineAdapter(
|
|
192
218
|
create_provider(httpx.MockTransport(provider_handler)),
|
|
193
219
|
ToolHostClient("internal-token"),
|
|
220
|
+
governance(),
|
|
194
221
|
)
|
|
195
222
|
request = runtime_request("business.records.list")
|
|
196
223
|
request = EngineRunRequest(
|
|
@@ -211,6 +238,7 @@ async def test_engine_denies_mismatched_provider_deployment() -> None:
|
|
|
211
238
|
engine = OpenAiCompatibleEngineAdapter(
|
|
212
239
|
create_provider(httpx.MockTransport(lambda _request: sse_response([]))),
|
|
213
240
|
ToolHostClient("internal-token"),
|
|
241
|
+
governance(),
|
|
214
242
|
)
|
|
215
243
|
request = runtime_request("business.records.list")
|
|
216
244
|
request = EngineRunRequest(
|
|
@@ -267,12 +295,63 @@ def runtime_request(tool_key: str) -> EngineRunRequest:
|
|
|
267
295
|
mode="configured-endpoint",
|
|
268
296
|
deployment_key="ai.openai-compatible",
|
|
269
297
|
),
|
|
298
|
+
provider_data_policy=provider_data_policy(),
|
|
270
299
|
tool_network_policy=ToolNetworkPolicy(mode="forbidden"),
|
|
271
300
|
execution_grant="grant_1",
|
|
272
301
|
tool_host_url="http://server:9747",
|
|
273
302
|
)
|
|
274
303
|
|
|
275
304
|
|
|
305
|
+
def provider_data_policy() -> ProviderDataPolicy:
|
|
306
|
+
return ProviderDataPolicy(
|
|
307
|
+
input_fields=[
|
|
308
|
+
ProviderInputFieldPolicy(
|
|
309
|
+
json_pointer="/question",
|
|
310
|
+
classification="internal",
|
|
311
|
+
dlp_action="redact",
|
|
312
|
+
)
|
|
313
|
+
],
|
|
314
|
+
envelope_fields=[
|
|
315
|
+
ProviderFieldPolicy(
|
|
316
|
+
field="instructions",
|
|
317
|
+
classification="internal",
|
|
318
|
+
dlp_action="reject",
|
|
319
|
+
),
|
|
320
|
+
ProviderFieldPolicy(
|
|
321
|
+
field="user-prompt",
|
|
322
|
+
classification="internal",
|
|
323
|
+
dlp_action="redact",
|
|
324
|
+
),
|
|
325
|
+
ProviderFieldPolicy(
|
|
326
|
+
field="tool-definitions",
|
|
327
|
+
classification="internal",
|
|
328
|
+
dlp_action="reject",
|
|
329
|
+
),
|
|
330
|
+
ProviderFieldPolicy(
|
|
331
|
+
field="provider-output",
|
|
332
|
+
classification="internal",
|
|
333
|
+
dlp_action="redact",
|
|
334
|
+
),
|
|
335
|
+
ProviderFieldPolicy(
|
|
336
|
+
field="tool-results",
|
|
337
|
+
classification="sensitive",
|
|
338
|
+
dlp_action="redact",
|
|
339
|
+
),
|
|
340
|
+
],
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def governance(path: str | None = None) -> ProviderRequestGovernance:
|
|
345
|
+
path = (
|
|
346
|
+
path
|
|
347
|
+
or tempfile.NamedTemporaryFile(
|
|
348
|
+
suffix=".sqlite3",
|
|
349
|
+
delete=False,
|
|
350
|
+
).name
|
|
351
|
+
)
|
|
352
|
+
return ProviderRequestGovernance(ProviderAuditStore(path, retention_days=90))
|
|
353
|
+
|
|
354
|
+
|
|
276
355
|
def sse_response(chunks: list[object]) -> httpx.Response:
|
|
277
356
|
body = "".join(
|
|
278
357
|
f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n" for chunk in chunks
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
import time
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal, cast
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from southwind_agent_server.contracts import (
|
|
12
|
+
ProviderDataPolicy,
|
|
13
|
+
ProviderFieldPolicy,
|
|
14
|
+
ProviderInputFieldPolicy,
|
|
15
|
+
)
|
|
16
|
+
from southwind_agent_server.engine import SafeEngineError
|
|
17
|
+
from southwind_agent_server.provider_audit import (
|
|
18
|
+
ProviderAttemptAudit,
|
|
19
|
+
ProviderAuditStore,
|
|
20
|
+
)
|
|
21
|
+
from southwind_agent_server.provider_governance import ProviderRequestGovernance
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_dlp_redacts_before_send_and_audits_metadata_only(tmp_path: Path) -> None:
|
|
25
|
+
path = tmp_path / "provider-audit.sqlite3"
|
|
26
|
+
governance = ProviderRequestGovernance(
|
|
27
|
+
ProviderAuditStore(str(path), retention_days=90)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
result = governance.authorize(
|
|
31
|
+
deployment_key="ai.openai-compatible",
|
|
32
|
+
operation_key="business.answer",
|
|
33
|
+
run_id="run_1",
|
|
34
|
+
attempt=1,
|
|
35
|
+
messages=[
|
|
36
|
+
{"role": "system", "content": "只回答问题"},
|
|
37
|
+
{"role": "user", "content": "联系 me@example.com"},
|
|
38
|
+
],
|
|
39
|
+
tools=(),
|
|
40
|
+
policy=policy(user_action="redact"),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
assert result.messages[1]["content"] == "联系 [REDACTED]"
|
|
44
|
+
row = (
|
|
45
|
+
sqlite3.connect(path)
|
|
46
|
+
.execute(
|
|
47
|
+
"""
|
|
48
|
+
select deployment_key, operation_key, run_id, attempt,
|
|
49
|
+
classifications, sent_bytes, decision
|
|
50
|
+
from provider_attempt_audit
|
|
51
|
+
"""
|
|
52
|
+
)
|
|
53
|
+
.fetchone()
|
|
54
|
+
)
|
|
55
|
+
assert row == (
|
|
56
|
+
"ai.openai-compatible",
|
|
57
|
+
"business.answer",
|
|
58
|
+
"run_1",
|
|
59
|
+
1,
|
|
60
|
+
"internal",
|
|
61
|
+
row[5],
|
|
62
|
+
"redact",
|
|
63
|
+
)
|
|
64
|
+
assert row[5] > 0
|
|
65
|
+
assert "me@example.com" not in path.read_bytes().decode(errors="ignore")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_missing_allowlist_and_dlp_reject_before_network(tmp_path: Path) -> None:
|
|
69
|
+
path = tmp_path / "provider-audit.sqlite3"
|
|
70
|
+
governance = ProviderRequestGovernance(
|
|
71
|
+
ProviderAuditStore(str(path), retention_days=90)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
with pytest.raises(SafeEngineError) as missing:
|
|
75
|
+
governance.authorize(
|
|
76
|
+
deployment_key="ai.openai-compatible",
|
|
77
|
+
operation_key="business.answer",
|
|
78
|
+
run_id="run_missing",
|
|
79
|
+
attempt=1,
|
|
80
|
+
messages=[{"role": "system", "content": "只回答问题"}],
|
|
81
|
+
tools=(),
|
|
82
|
+
policy=ProviderDataPolicy(input_fields=[], envelope_fields=[]),
|
|
83
|
+
)
|
|
84
|
+
assert missing.value.code == "FORBIDDEN"
|
|
85
|
+
|
|
86
|
+
with pytest.raises(SafeEngineError) as rejected:
|
|
87
|
+
governance.authorize(
|
|
88
|
+
deployment_key="ai.openai-compatible",
|
|
89
|
+
operation_key="business.answer",
|
|
90
|
+
run_id="run_reject",
|
|
91
|
+
attempt=1,
|
|
92
|
+
messages=[
|
|
93
|
+
{"role": "system", "content": "只回答问题"},
|
|
94
|
+
{"role": "user", "content": "password=do-not-send"},
|
|
95
|
+
],
|
|
96
|
+
tools=(),
|
|
97
|
+
policy=policy(user_action="reject"),
|
|
98
|
+
)
|
|
99
|
+
assert rejected.value.code == "FORBIDDEN"
|
|
100
|
+
decisions = (
|
|
101
|
+
sqlite3.connect(path)
|
|
102
|
+
.execute(
|
|
103
|
+
"select decision, sent_bytes from provider_attempt_audit "
|
|
104
|
+
"order by occurred_at"
|
|
105
|
+
)
|
|
106
|
+
.fetchall()
|
|
107
|
+
)
|
|
108
|
+
assert decisions == [("reject", 0), ("reject", 0)]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_attempts_are_unique_append_only_and_ttl_purge_is_bounded(
|
|
112
|
+
tmp_path: Path,
|
|
113
|
+
) -> None:
|
|
114
|
+
path = tmp_path / "provider-audit.sqlite3"
|
|
115
|
+
store = ProviderAuditStore(str(path), retention_days=1)
|
|
116
|
+
event = ProviderAttemptAudit(
|
|
117
|
+
"ai.openai-compatible",
|
|
118
|
+
"business.answer",
|
|
119
|
+
"run_1",
|
|
120
|
+
1,
|
|
121
|
+
("internal",),
|
|
122
|
+
42,
|
|
123
|
+
"allow",
|
|
124
|
+
)
|
|
125
|
+
store.append(event)
|
|
126
|
+
with pytest.raises(sqlite3.IntegrityError):
|
|
127
|
+
store.append(event)
|
|
128
|
+
|
|
129
|
+
database = sqlite3.connect(path)
|
|
130
|
+
database.execute(
|
|
131
|
+
"update provider_attempt_audit set occurred_at = ?",
|
|
132
|
+
(time.time() - 2 * 24 * 60 * 60,),
|
|
133
|
+
)
|
|
134
|
+
database.commit()
|
|
135
|
+
database.close()
|
|
136
|
+
|
|
137
|
+
assert store.purge_expired() == 1
|
|
138
|
+
health = store.health()
|
|
139
|
+
assert health["retainedRecords"] == 0
|
|
140
|
+
assert health["lastDeletedRecords"] == 1
|
|
141
|
+
assert health["totalDeletedRecords"] == 1
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_audit_store_serializes_concurrent_single_replica_writes(
|
|
145
|
+
tmp_path: Path,
|
|
146
|
+
) -> None:
|
|
147
|
+
store = ProviderAuditStore(
|
|
148
|
+
str(tmp_path / "provider-audit.sqlite3"),
|
|
149
|
+
retention_days=90,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def append(attempt: int) -> None:
|
|
153
|
+
store.append(
|
|
154
|
+
ProviderAttemptAudit(
|
|
155
|
+
"ai.openai-compatible",
|
|
156
|
+
"business.answer",
|
|
157
|
+
"run_concurrent",
|
|
158
|
+
attempt,
|
|
159
|
+
("internal",),
|
|
160
|
+
42,
|
|
161
|
+
"allow",
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
with ThreadPoolExecutor(max_workers=4) as executor:
|
|
166
|
+
list(executor.map(append, range(1, 21)))
|
|
167
|
+
|
|
168
|
+
assert store.health()["retainedRecords"] == 20
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_audit_failure_denies_provider_attempt() -> None:
|
|
172
|
+
class BrokenAudit:
|
|
173
|
+
def append(self, _event: ProviderAttemptAudit) -> None:
|
|
174
|
+
raise sqlite3.OperationalError("do-not-expose")
|
|
175
|
+
|
|
176
|
+
governance = ProviderRequestGovernance(cast(ProviderAuditStore, BrokenAudit()))
|
|
177
|
+
with pytest.raises(SafeEngineError) as captured:
|
|
178
|
+
governance.authorize(
|
|
179
|
+
deployment_key="ai.openai-compatible",
|
|
180
|
+
operation_key="business.answer",
|
|
181
|
+
run_id="run_audit_failure",
|
|
182
|
+
attempt=1,
|
|
183
|
+
messages=[
|
|
184
|
+
{"role": "system", "content": "只回答问题"},
|
|
185
|
+
{"role": "user", "content": "正常内容"},
|
|
186
|
+
],
|
|
187
|
+
tools=(),
|
|
188
|
+
policy=policy(user_action="redact"),
|
|
189
|
+
)
|
|
190
|
+
assert captured.value.code == "FORBIDDEN"
|
|
191
|
+
assert "do-not-expose" not in str(captured.value)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def policy(*, user_action: Literal["reject", "redact"]) -> ProviderDataPolicy:
|
|
195
|
+
return ProviderDataPolicy(
|
|
196
|
+
input_fields=[
|
|
197
|
+
ProviderInputFieldPolicy(
|
|
198
|
+
json_pointer="/question",
|
|
199
|
+
classification="internal",
|
|
200
|
+
dlp_action="redact",
|
|
201
|
+
)
|
|
202
|
+
],
|
|
203
|
+
envelope_fields=[
|
|
204
|
+
ProviderFieldPolicy(
|
|
205
|
+
field="instructions",
|
|
206
|
+
classification="internal",
|
|
207
|
+
dlp_action="reject",
|
|
208
|
+
),
|
|
209
|
+
ProviderFieldPolicy(
|
|
210
|
+
field="user-prompt",
|
|
211
|
+
classification="internal",
|
|
212
|
+
dlp_action=user_action,
|
|
213
|
+
),
|
|
214
|
+
],
|
|
215
|
+
)
|