From b6ad802acec1ffd5e030310ecec2175f9987b044 Mon Sep 17 00:00:00 2001 From: frf12 Date: Sun, 8 Nov 2026 22:41:06 +0800 Subject: [PATCH] fix(gateway): honor configured privacy coverage --- docs/en/docs/operate/context-protection.md | 12 ++- docs/zh/docs/operate/context-protection.md | 12 ++- gateway/crates/pc-gateway-app/src/main.rs | 3 +- .../crates/pc-gateway-core/src/executor.rs | 47 +++++--- .../tests/executor_semantics.rs | 100 +++++++++++++++++- .../builtin/runtime/context_check.py | 23 ++-- tests/builtin/runtime/test_context_check.py | 72 ++++++++++--- tests/e2e/test_context_check_authorization.py | 33 ++++-- tests/e2e/test_context_external_executor.py | 43 ++++++++ tests/e2e/test_gateway_authentication.py | 5 +- 10 files changed, 298 insertions(+), 52 deletions(-) diff --git a/docs/en/docs/operate/context-protection.md b/docs/en/docs/operate/context-protection.md index 67923b8770..46445d0d02 100644 --- a/docs/en/docs/operate/context-protection.md +++ b/docs/en/docs/operate/context-protection.md @@ -5,7 +5,7 @@ description: Reuse exact prepared Atomic Memory and check final requests and res # Context reuse and final admission -Prepare context once, retain its short-lived reuse proof, and validate the exact bytes before each model request and complete response delivery. Configure an enforcing target using [Inference privacy protection](inference-protection.md) before using the public admission API. The caller owns model execution and delivery; a check does not execute a model or authorize spending. +Prepare context once, retain its short-lived reuse proof, and validate the exact bytes before each model request and complete response delivery. Configure a target and privacy mode using [Inference privacy protection](inference-protection.md). Callers that require complete privacy checks must use enforcing mode. The caller owns model execution and delivery; a check does not execute a model or authorize spending. Before upgrading a Server with this API, follow the [coordinated SDK/CLI upgrade requirements](../get-started/install-and-run.md#update-or-replace-an-installation). Older clients reject the new capability field even when protection is disabled. @@ -99,10 +99,16 @@ the prepared value. A gateway must not mask an approved request or restore value reuse that approval for the changed bytes. A changed payload needs its own admission; this API supplies no placeholder mapping or authorization to reverse a privacy transformation. -Only `allow` with `coverage.complete=true` permits the corresponding action. `block` and `withhold` return HTTP +Callers requiring complete privacy checks accept only `allow` with `coverage.complete=true`. `block` and `withhold` return HTTP 200 because a decision was made, but neither permits execution. Invalid carriers/locators return 422; authorization failure returns 401/403; an oversized payload returns 413. If the required check is unavailable, do not send or release. -The public operation withholds when enforcing is not configured. +In `disabled`, `shadow`, and `advisory` modes, Core can return `allow` with a binding after validating authorization +and context reuse, but reports `coverage.complete=false`. Disabled skips privacy inspection; shadow and advisory +observe requests without blocking and do not inspect responses. Enforcing retains request and response enforcement. +Gateway accepts incomplete privacy coverage only when its trusted Core capability discovery explicitly reports +`enforcing=false`; it still requires an allowed decision, a valid binding, and an unexpired deadline. Discovery failure +does not enable this behavior. Restart Gateway after changing the Core privacy mode to refresh its startup discovery. +The reference external executor continues to require complete privacy coverage. An allowed send returns a payload-, target-, policy-, identity-, and Scope-bound permit. Its 60-second send deadline is separate from its five-minute response-correlation deadline. Pass it as `request_binding` to diff --git a/docs/zh/docs/operate/context-protection.md b/docs/zh/docs/operate/context-protection.md index c289f0cef5..631907daf7 100644 --- a/docs/zh/docs/operate/context-protection.md +++ b/docs/zh/docs/operate/context-protection.md @@ -5,7 +5,7 @@ description: 复用精确的 Atomic Memory 上下文,并按当前授权检查 # 上下文复用与最终准入 -准备一次上下文,保留短期复用证明,在每次模型请求发送前和完整响应交付前验证实际字节。使用公开准入 API 前,先按[推理隐私保护](inference-protection.md)配置 enforcing 目标。调用方负责模型执行与响应交付;检查本身不会调用模型,也不会授予模型调用额度。 +准备一次上下文,保留短期复用证明,在每次模型请求发送前和完整响应交付前验证实际字节。先按[推理隐私保护](inference-protection.md)配置目标和隐私模式;需要完整隐私检查的调用方必须使用 enforcing 模式。调用方负责模型执行与响应交付;检查本身不会调用模型,也不会授予模型调用额度。 升级带有此 API 的 Server 前,请遵循 [SDK/CLI 协调升级要求](../get-started/install-and-run.md#更新或替换安装)。即使关闭保护,旧客户端仍会拒绝新增的能力字段。 @@ -93,10 +93,16 @@ JSON pointer,以及解码后 UTF-8 文本的 `start_byte`、`end_byte` 半开 网关不能在请求获准后脱敏,或在响应获准后还原值,再把原批准用于变化后的字节。 变化后的载荷需要单独准入;本 API 不提供占位符映射,也不授予逆转隐私变换的权限。 -只有 `allow` 且 `coverage.complete=true` 才能继续对应动作。 +要求完整隐私检查的调用方,只有在 `allow` 且 `coverage.complete=true` 时才能继续对应动作。 `block`、`withhold` 虽然返回 HTTP 200,但都禁止执行;只判断 HTTP 成功是不够的。 载体或位置不合法返回 422,权限失败返回 401/403,正文超限返回 413。 -必需检查不可用时不发送、不交付;未配置 enforcing 的公开检查只返回暂缓。 +必需检查不可用时不发送、不交付。 +在 `disabled`、`shadow` 和 `advisory` 模式下,Core 验证授权和上下文复用后可以返回 `allow` 和绑定, +但会如实返回 `coverage.complete=false`。Disabled 跳过隐私检查;shadow 和 advisory 对请求进行不阻断的观察, +不检查响应;enforcing 保留请求和响应的强制检查。 +Gateway 只有在可信的 Core 能力发现明确返回 `enforcing=false` 时才接受不完整隐私覆盖, +仍要求允许的决定、有效绑定和未过期的期限;能力发现失败不会开启这种行为。 +修改 Core 隐私模式后应重启 Gateway,刷新启动时获取的模式信息。外部执行器示例仍要求完整隐私覆盖。 发送批准绑定准确载荷、目标、策略、身份和 Scope。60 秒发送期限与五分钟响应关联期限分开: 把发送批准的 `binding` 作为 `before_release.request_binding`,提交完整响应载体及 diff --git a/gateway/crates/pc-gateway-app/src/main.rs b/gateway/crates/pc-gateway-app/src/main.rs index c22b445c56..1a73031199 100644 --- a/gateway/crates/pc-gateway-app/src/main.rs +++ b/gateway/crates/pc-gateway-app/src/main.rs @@ -175,7 +175,8 @@ async fn main() { ReqwestTransport::new(Duration::from_secs(120)), NonZeroU32::new(max_attempts).expect("max_attempts > 0"), ) - .with_input_limit(model_body_bytes), + .with_input_limit(model_body_bytes) + .with_core_enforcement(capabilities.as_ref().is_none_or(|caps| caps.enforcing)), ), evidence, upstream_profile: std::env::var("PC_GATEWAY_UPSTREAM_PROFILE") diff --git a/gateway/crates/pc-gateway-core/src/executor.rs b/gateway/crates/pc-gateway-core/src/executor.rs index 380b401a8c..d512b870d9 100644 --- a/gateway/crates/pc-gateway-core/src/executor.rs +++ b/gateway/crates/pc-gateway-core/src/executor.rs @@ -128,6 +128,7 @@ pub struct Executor { /// 会话内按消息锚点重放的准备结果(RFC 2199)。 replays: ReplayCache, max_input_bytes: usize, + require_privacy_coverage: bool, } /// 单次发送尝试的准入凭证。 @@ -150,6 +151,7 @@ where max_attempts, replays: ReplayCache::default(), max_input_bytes: pc_gateway_types::CONTEXT_CHECK_BODY_LIMIT, + require_privacy_coverage: true, } } @@ -160,6 +162,13 @@ where self } + /// Apply enforcement discovered from the trusted Core. Non-enforcing + /// admission still requires Core's allow verdict, binding, and deadline. + pub fn with_core_enforcement(mut self, enforcing: bool) -> Self { + self.require_privacy_coverage = enforcing; + self + } + pub async fn run( &self, control: RequestControl, @@ -280,7 +289,11 @@ where }; } Ok(result) => match result.decision { - CheckDecision::Allow => match validate_allow(&result, CheckPhase::BeforeSend) { + CheckDecision::Allow => match validate_allow( + &result, + CheckPhase::BeforeSend, + self.require_privacy_coverage, + ) { Ok((binding, deadline)) => Admission { binding, valid_until_ms: deadline, @@ -384,7 +397,11 @@ where }; match self.gate.check(recheck).await { Ok(r) if r.decision == CheckDecision::Allow => { - match validate_allow(&r, CheckPhase::BeforeSend) { + match validate_allow( + &r, + CheckPhase::BeforeSend, + self.require_privacy_coverage, + ) { Ok((binding, deadline)) => { current = Admission { binding, @@ -437,12 +454,15 @@ where Err(e) => ManagedOutcome::GatePaused(classify_gate_error(e)), Ok(result) => match result.decision { CheckDecision::Allow => { - // P2 review:验证完整 allow(schema/phase/coverage/binding)。 - let (_release_binding_used, _release_deadline_used) = - match validate_allow(&result, CheckPhase::BeforeRelease) { - Ok(v) => v, - Err(outcome) => return outcome, - }; + // Release requires its own valid admission under the configured mode. + let (_release_binding_used, _release_deadline_used) = match validate_allow( + &result, + CheckPhase::BeforeRelease, + self.require_privacy_coverage, + ) { + Ok(v) => v, + Err(outcome) => return outcome, + }; // P1-4:释放前验 release verdict 的 valid_until(fail-closed)。 let release_deadline = parse_rfc3339_ms(&result.valid_until).unwrap_or(0); if now_ms() >= release_deadline { @@ -766,12 +786,13 @@ fn content_headers(headers: &[(String, String)], content_type: &str) -> BTreeMap out } -/// 验证 allow 响应的完整性:schema/phase/coverage/binding 四项缺一不可。 -/// 契约:"Only allow with complete coverage permits the corresponding phase." -/// 畸形对端返回不完整 allow 时必须暂停(fail-closed),不得替代或放行。 +/// Validate phase, binding and deadline independently of privacy enforcement. +/// Complete privacy coverage is required unless the trusted Core explicitly +/// advertises non-enforcing operation. fn validate_allow( result: &ContextCheckResult, expected_phase: CheckPhase, + require_privacy_coverage: bool, ) -> Result<(String, u64), ManagedOutcome> { // 1. schema 必须匹配 if result.schema != pc_gateway_types::CONTEXT_CHECK_SCHEMA { @@ -786,8 +807,8 @@ fn validate_allow( result.phase ))); } - // 3. coverage.complete 必须为 true - if !result.coverage.complete { + // 3. Enforcing admission requires complete privacy coverage. + if require_privacy_coverage && !result.coverage.complete { return Err(ManagedOutcome::GatePaused( "malformed_verdict: incomplete coverage on allow".into(), )); diff --git a/gateway/crates/pc-gateway-core/tests/executor_semantics.rs b/gateway/crates/pc-gateway-core/tests/executor_semantics.rs index f6f5b6760b..c53e2282d7 100644 --- a/gateway/crates/pc-gateway-core/tests/executor_semantics.rs +++ b/gateway/crates/pc-gateway-core/tests/executor_semantics.rs @@ -1707,10 +1707,104 @@ async fn prepare_requests_memory_only_assembly() { // ---------- P2 allow 完整性验证探针 ---------- +#[tokio::test] +async fn non_enforcing_core_can_admit_retry_and_release_without_privacy_coverage() { + let verdicts = [ + (CheckPhase::BeforeSend, "initial"), + (CheckPhase::BeforeSend, "retry"), + (CheckPhase::BeforeRelease, "release"), + ] + .into_iter() + .map(|(phase, binding)| { + let mut verdict = result(phase, CheckDecision::Allow, &[]); + verdict.coverage.complete = false; + verdict.binding = Some(binding.into()); + Ok(verdict) + }) + .collect(); + let (executor, renderer, transport, gate) = harness( + verdicts, + vec![ + SendOutcome::NotSent(pc_gateway_transport::NotSentReason::ConnectRefused), + raw_response(), + ], + 2, + ); + let outcome = executor + .with_core_enforcement(false) + .run( + request_control(), + call(DegradePolicy::Pause), + &RendererRef(renderer), + &NoopEvidenceSink, + ) + .await; + assert!( + matches!(outcome, ManagedOutcome::Released { binding: Some(ref b), .. } if b == "release") + ); + assert_eq!(*transport.bindings.lock().unwrap(), ["initial", "retry"]); + assert_eq!(gate.seen.lock().unwrap().last().unwrap().1, "retry"); +} + +#[tokio::test] +async fn non_enforcing_core_still_requires_valid_admission_for_each_phase() { + for phase in [CheckPhase::BeforeSend, CheckPhase::BeforeRelease] { + for invalid in [ + "binding", "deadline", "schema", "phase", "block", "withhold", + ] { + let mut verdict = result(phase, CheckDecision::Allow, &[]); + verdict.coverage.complete = false; + match invalid { + "binding" => verdict.binding = None, + "deadline" => verdict.valid_until = "2000-01-01T00:00:00Z".into(), + "schema" => verdict.schema = "invalid".into(), + "phase" => { + verdict.phase = if phase == CheckPhase::BeforeSend { + CheckPhase::BeforeRelease + } else { + CheckPhase::BeforeSend + } + } + "block" => verdict.decision = CheckDecision::Block, + "withhold" => verdict.decision = CheckDecision::Withhold, + _ => unreachable!(), + } + let mut verdicts = Vec::new(); + if phase == CheckPhase::BeforeRelease { + verdicts.push(Ok(result( + CheckPhase::BeforeSend, + CheckDecision::Allow, + &[], + ))); + } + verdicts.push(Ok(verdict)); + let (executor, renderer, transport, _) = harness(verdicts, vec![raw_response()], 1); + let outcome = executor + .with_core_enforcement(false) + .run( + request_control(), + call(DegradePolicy::Pause), + &RendererRef(renderer), + &NoopEvidenceSink, + ) + .await; + match invalid { + "deadline" => assert!(matches!(outcome, ManagedOutcome::AdmissionExpired)), + "block" => assert!(matches!(outcome, ManagedOutcome::Blocked { .. })), + "withhold" => assert!(matches!(outcome, ManagedOutcome::Withheld { .. })), + _ => assert!(matches!(outcome, ManagedOutcome::GatePaused(_))), + } + assert_eq!( + transport.sends.lock().unwrap().len(), + usize::from(phase == CheckPhase::BeforeRelease) + ); + } + } +} + #[tokio::test] async fn allow_with_incomplete_coverage_pauses() { - // 契约:"Only allow with complete coverage permits the corresponding phase." - // coverage.complete=false 的 allow 是畸形 → 必须暂停。 + // Without an explicit non-enforcing configuration, incomplete coverage pauses. let (executor, renderer, transport, _gate) = harness( vec![Ok(ContextCheckResult { coverage: pc_gateway_types::ContextCheckCoverage { @@ -1818,7 +1912,7 @@ async fn allow_with_wrong_schema_pauses() { #[tokio::test] async fn release_allow_with_incomplete_coverage_pauses() { - // 释放阶段同样验证——incomplete coverage 的 release allow 不放行内容。 + // The default coverage requirement also applies to release admission. let (executor, renderer, _transport, _gate) = harness( vec![ Ok(result(CheckPhase::BeforeSend, CheckDecision::Allow, &[])), diff --git a/src/powercontext/builtin/runtime/context_check.py b/src/powercontext/builtin/runtime/context_check.py index d8aab38f8b..5b8c867adc 100644 --- a/src/powercontext/builtin/runtime/context_check.py +++ b/src/powercontext/builtin/runtime/context_check.py @@ -283,7 +283,10 @@ async def _validate_prepared( profile = config.targets[request.target_profile] if profile.require_prepared_contexts and not request.prepared_contexts: raise InvalidRuntimeRequestError("context-binding-required") - body = parse_model_json(request.payload.body.encode("utf-8"), max_depth=config.max_json_depth) + try: + body = parse_model_json(request.payload.body.encode("utf-8"), max_depth=config.max_json_depth) + except (ValueError, RecursionError): + raise InvalidRuntimeRequestError("context-payload-invalid") from None previous: tuple[int, int, int] | None = None locations: dict[str, list[tuple[int, int]]] = {} expiry: datetime | None = None @@ -356,13 +359,18 @@ async def check_context( limit = config.max_payload_bytes if request.phase == "before_send" else config.max_response_bytes if len(body) > min(limit, MAX_CHECK_BODY_BYTES): raise ContextCheckTooLargeError - if config.mode != "enforcing": - return _withheld(request, config, now, "enforcement-not-configured") + if request.target_profile not in config.targets: + return _withheld(request, config, now, "target_unsupported") policy = PrivacyPolicy(config) - assessed = policy.inspect_json( - body, phase=request.phase, target_profile=request.target_profile, headers=request.payload.headers + # Match the existing transport: observation modes do not inspect responses. + assessed = ( + policy.inspect_json( + body, phase=request.phase, target_profile=request.target_profile, headers=request.payload.headers + ) + if policy.enforcing or request.phase == "before_send" + else policy.decision(request.phase, "allow") ) - if assessed.decision != "allow" or not assessed.coverage.complete: + if policy.enforcing and (assessed.decision != "allow" or not assessed.coverage.complete): return ContextCheckResult( phase=request.phase, decision=assessed.decision if assessed.decision != "allow" else "withhold", @@ -416,7 +424,8 @@ async def check_context( return ContextCheckResult( phase=request.phase, decision="allow", - coverage=assessed.coverage, + reason_codes=assessed.reason_codes, + coverage=assessed.coverage if policy.enforcing else PrivacyCoverage(complete=False), policy_version=config.policy_version, valid_until=deadline, correlate_until=correlate_until, diff --git a/tests/builtin/runtime/test_context_check.py b/tests/builtin/runtime/test_context_check.py index b23f1a6cba..efe75c1a11 100644 --- a/tests/builtin/runtime/test_context_check.py +++ b/tests/builtin/runtime/test_context_check.py @@ -137,18 +137,20 @@ async def test_null_error_does_not_authorize_incomplete_output(protocol): @pytest.mark.anyio -async def test_separate_send_deadline_and_response_correlation(): +@pytest.mark.parametrize("mode", ["disabled", "shadow", "advisory", "enforcing"]) +async def test_separate_send_deadline_and_response_correlation(mode): codec = BindingCodec() - send = await _check(_send(), codec) + config = _config(mode=mode) + send = await _check(_send(), codec, config=config) assert send.decision == "allow" and send.binding assert send.valid_until == NOW + timedelta(seconds=60) assert send.correlate_until == NOW + timedelta(seconds=300) - release = await _check(_release(send.binding), codec, now=NOW + timedelta(seconds=90)) + release = await _check(_release(send.binding), codec, config=config, now=NOW + timedelta(seconds=90)) assert release.decision == "allow" and release.binding assert release.correlate_until is None - wrong_phase = await _check(_release(release.binding), codec) + wrong_phase = await _check(_release(release.binding), codec, config=config) assert wrong_phase.decision == "withhold" - expired = await _check(_release(send.binding), codec, now=NOW + timedelta(seconds=301)) + expired = await _check(_release(send.binding), codec, config=config, now=NOW + timedelta(seconds=301)) assert expired.decision == "withhold" @@ -182,13 +184,56 @@ async def test_raw_request_and_response_block_without_echoing_content(): @pytest.mark.anyio -async def test_shadow_never_issues_an_execution_binding(): - result = await _check(_send(), BindingCodec(), config=_config(mode="shadow")) - assert result.decision == "withhold" and not result.coverage.complete and result.binding is None +@pytest.mark.parametrize("mode", ["disabled", "shadow", "advisory", "enforcing"]) +async def test_send_and_release_honor_privacy_mode(mode): + codec = BindingCodec() + config = _config(mode=mode) + send = await _check(_send("synthetic-forbidden-value"), codec, config=config) + if mode == "enforcing": + assert send.decision == "block" and send.binding is None + send = await _check(_send(), codec, config=config) + assert send.decision == "allow" and send.binding + assert send.coverage.complete is (mode == "enforcing") + if mode in ("shadow", "advisory"): + assert send.reason_codes == ("forbidden_content",) + release = await _check(_release(send.binding, "synthetic-forbidden-value"), codec, config=config) + if mode == "enforcing": + assert release.decision == "block" and release.binding is None + else: + assert release.decision == "allow" and release.binding and not release.coverage.complete + assert "synthetic-forbidden-value" not in send.model_dump_json() + release.model_dump_json() + + +@pytest.mark.anyio +@pytest.mark.parametrize("mode", ["disabled", "shadow", "advisory"]) +async def test_non_enforcing_modes_allow_uninspected_content(mode): + codec = BindingCodec() + config = _config(mode=mode) + request = _send( + payload=_payload({ + "model": "test", + "max_tokens": 64, + "messages": [{"role": "user", "content": [{"type": "image_url", "image_url": {"url": "test-image"}}]}], + }) + ) + send = await _check(request, codec, config=config) + assert send.decision == "allow" and send.binding and not send.coverage.complete + release = _release(send.binding).model_copy(update={"payload": _payload({"image": "test-image"})}) + result = await _check(release, codec, config=config) + assert result.decision == "allow" and result.binding and not result.coverage.complete + + +@pytest.mark.anyio +@pytest.mark.parametrize("mode", ["disabled", "shadow", "advisory", "enforcing"]) +async def test_unknown_target_is_withheld(mode): + result = await _check(_send(target_profile="missing"), BindingCodec(), config=_config(mode=mode)) + assert result.decision == "withhold" and result.reason_codes == ("target_unsupported",) + assert result.binding is None @pytest.mark.anyio -async def test_utf8_exact_block_requires_user_position_and_complete_binding(): +@pytest.mark.parametrize("mode", ["disabled", "shadow", "advisory", "enforcing"]) +async def test_utf8_exact_block_requires_user_position_and_complete_binding(mode): block = "BEGIN_POWERCONTEXT_PREPARED_CONTEXT_V1\n历史资料\nEND_POWERCONTEXT_PREPARED_CONTEXT_V1" prefix = "当前任务: \n" @@ -210,10 +255,11 @@ async def validate(binding): }, } request = _send(prefix + block, prepared_contexts=(location,)) - result = await _check(request, BindingCodec(), validate_reuse=validate) + config = _config(mode=mode) + result = await _check(request, BindingCodec(), config=config, validate_reuse=validate) assert result.decision == "allow" with pytest.raises(InvalidRuntimeRequestError, match="binding-required"): - await _check(_send(prefix + block), BindingCodec()) + await _check(_send(prefix + block), BindingCodec(), config=config) promoted = _send( payload=_payload({ "model": "test", @@ -223,10 +269,10 @@ async def validate(binding): prepared_contexts=(location,), ) with pytest.raises(InvalidRuntimeRequestError, match="location-trust"): - await _check(promoted, BindingCodec(), validate_reuse=validate) + await _check(promoted, BindingCodec(), config=config, validate_reuse=validate) altered = _send(prefix + block.replace("资料", "伪造"), prepared_contexts=(location,)) with pytest.raises(InvalidRuntimeRequestError, match="location-content"): - await _check(altered, BindingCodec(), validate_reuse=validate) + await _check(altered, BindingCodec(), config=config, validate_reuse=validate) @pytest.mark.anyio diff --git a/tests/e2e/test_context_check_authorization.py b/tests/e2e/test_context_check_authorization.py index c7b091d2c0..c9b1e14cfc 100644 --- a/tests/e2e/test_context_check_authorization.py +++ b/tests/e2e/test_context_check_authorization.py @@ -28,10 +28,10 @@ from tests.e2e.test_atomic_memory_prepare_lifecycle import _change, _remember, _revoke -def _inference(): +def _inference(mode="enforcing"): return InferenceConfig( protection=InferenceProtectionConfig( - mode="enforcing", + mode=mode, forbidden_values=(SecretStr("synthetic-forbidden-value"),), targets={ "model": UpstreamProfile( @@ -73,6 +73,22 @@ def _send(scope_id, content, binding=None): } +@pytest.mark.parametrize("mode", ["disabled", "shadow", "advisory"]) +def test_non_enforcing_check_rejects_invalid_json_without_server_error(tmp_path, mode): + async def scenario(): + async with _server(tmp_path, inference=_inference(mode)) as (_, client, _): + scope_id = await _scope(client) + request = _send(scope_id, "safe") + for body in ('{"private-value":', '{"model":"one","model":"two"}', "[]"): + request["payload"]["body"] = body + response = await client.post("/v1/context/check", json=request) + assert response.status_code == 422, response.text + assert response.json()["error"]["code"] == "invalid_request" + assert "private-value" not in response.text + + asyncio.run(scenario()) + + def test_public_responses_check_requires_explicit_non_storing_input(tmp_path): async def scenario(): inference = _inference() @@ -198,9 +214,10 @@ async def _prepare(client, scope_id, *, headers=None, reuse=True, receipt=False, @pytest.mark.parametrize("receipt", [False, True]) @pytest.mark.parametrize("assembled", [False, True], ids=["context", "text"]) -def test_public_envelope_send_release_and_exact_revision_invalidation(tmp_path, receipt, assembled): +@pytest.mark.parametrize("mode", ["disabled", "shadow", "advisory", "enforcing"]) +def test_public_envelope_send_release_and_exact_revision_invalidation(tmp_path, receipt, assembled, mode): async def scenario(): - async with _server(tmp_path, inference=_inference()) as (_, client, _): + async with _server(tmp_path, inference=_inference(mode)) as (_, client, _): scope_id = await _scope(client) record = await _remember(client, scope_id, "Alpha deployment uses port 8123.") ordinary = await _prepare(client, scope_id, reuse=False, assembled=assembled) @@ -225,7 +242,7 @@ async def scenario(): sent = await client.post("/v1/context/check", json=request) assert sent.status_code == 200, sent.text assert sent.json()["decision"] == "allow" - assert sent.json()["coverage"]["complete"] is True + assert sent.json()["coverage"]["complete"] is (mode == "enforcing") release = { "scope_id": scope_id, "phase": "before_release", @@ -249,6 +266,7 @@ async def scenario(): response = await client.post("/v1/context/check", json=release) assert response.status_code == 200, response.text assert response.json()["decision"] == "allow" + assert response.json()["coverage"]["complete"] is (mode == "enforcing") assert "correlate_until" not in response.json() await _change(client, scope_id, record, "revise") stale = await client.post("/v1/context/check", json=request) @@ -261,9 +279,10 @@ async def scenario(): asyncio.run(scenario()) -def test_current_authorization_and_identity_are_required_for_each_check(tmp_path): +@pytest.mark.parametrize("mode", ["disabled", "shadow", "advisory", "enforcing"]) +def test_current_authorization_and_identity_are_required_for_each_check(tmp_path, mode): async def scenario(): - async with _server(tmp_path, inference=_inference()) as (_, client, _): + async with _server(tmp_path, inference=_inference(mode)) as (_, client, _): scope_id = await _scope(client) await _remember(client, scope_id, "Alpha uses isolated deployment.") grant = await _grant(client, scope_id, "alice", "scope.viewer") diff --git a/tests/e2e/test_context_external_executor.py b/tests/e2e/test_context_external_executor.py index 5bd35c01df..2796e4c1e8 100644 --- a/tests/e2e/test_context_external_executor.py +++ b/tests/e2e/test_context_external_executor.py @@ -61,6 +61,49 @@ def _response(text: str) -> bytes: ).encode() +@pytest.mark.parametrize("mode", ["disabled", "shadow", "advisory"]) +def test_external_executor_requires_privacy_coverage_even_when_core_allows(tmp_path, mode): + async def scenario(server): + endpoint = f"http://127.0.0.1:{server.server_port}/v1/chat/completions" + async with _server(tmp_path, inference=_inference(mode)) as (_, http, _): + scope_id = await _scope(http) + client = PowerContextClient("http://localhost", http_client=http) + payload = ModelJsonPayload.model_validate({ + "schema": "powercontext.model-json-payload.v1", + "headers": {"content-type": "application/json"}, + "body": json.dumps({ + "model": "test", + "max_tokens": 64, + "messages": [{"role": "user", "content": "safe"}], + }), + }) + admission = await http.post( + "/v1/context/check", + json={ + "scope_id": scope_id, + "phase": "before_send", + "target_profile": "model", + "payload": payload.model_dump(mode="json", by_alias=True), + "prepared_contexts": [], + }, + ) + assert admission.status_code == 200, admission.text + assert admission.json()["decision"] == "allow" and admission.json()["binding"] + assert admission.json()["coverage"]["complete"] is False + # The SDK already rejects incomplete allow results before model I/O. + with pytest.raises(ExecutionRefused, match="execution_unavailable_or_uncertain"): + await execute_checked_json( + powercontext=client, + target=TrustedTarget(profile_id="model", endpoint=endpoint), + scope_id=scope_id, + payload=payload, + ) + assert server.received == [] + + with upstream(_response("safe")) as server: + asyncio.run(scenario(server)) + + def test_external_executor_preserves_approved_bytes_and_withholds_blocked_content(tmp_path): safe_output = _response("继续使用端口 8123。保留字面模板 {{PROJECT_abcdefgh}}。不推测其真实值。") diff --git a/tests/e2e/test_gateway_authentication.py b/tests/e2e/test_gateway_authentication.py index 0b5dc01aa9..54a3e04ecb 100644 --- a/tests/e2e/test_gateway_authentication.py +++ b/tests/e2e/test_gateway_authentication.py @@ -155,8 +155,9 @@ def test_loopback_listener_retains_no_key_compatibility( @pytest.mark.parametrize("route", ["chat/completions", "responses"]) +@pytest.mark.parametrize("mode", ["disabled", "shadow", "advisory", "enforcing"]) def test_model_ingress_authentication_with_real_core_and_provider( - gateway_binary: Path, tmp_path: Path, short_tmp_path: Path, route: str + gateway_binary: Path, tmp_path: Path, short_tmp_path: Path, route: str, mode: str ) -> None: response_body = ( completion("gateway accepted") @@ -181,7 +182,7 @@ def test_model_ingress_authentication_with_real_core_and_provider( environment = _environment(tmp_path) environment["POWERCONTEXT_SERVER_INFERENCE"] = json.dumps({ "protection": { - "mode": "enforcing", + "mode": mode, "policy_version": "gateway-auth.v1", "targets": { "auth-test": {