Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions docs/en/docs/operate/context-protection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions docs/zh/docs/operate/context-protection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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#更新或替换安装)。即使关闭保护,旧客户端仍会拒绝新增的能力字段。

Expand Down Expand Up @@ -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`,提交完整响应载体及
Expand Down
3 changes: 2 additions & 1 deletion gateway/crates/pc-gateway-app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
47 changes: 34 additions & 13 deletions gateway/crates/pc-gateway-core/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ pub struct Executor<G, T> {
/// 会话内按消息锚点重放的准备结果(RFC 2199)。
replays: ReplayCache,
max_input_bytes: usize,
require_privacy_coverage: bool,
}

/// 单次发送尝试的准入凭证。
Expand All @@ -150,6 +151,7 @@ where
max_attempts,
replays: ReplayCache::default(),
max_input_bytes: pc_gateway_types::CONTEXT_CHECK_BODY_LIMIT,
require_privacy_coverage: true,
}
}

Expand All @@ -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<R: WireRenderer>(
&self,
control: RequestControl,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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(),
));
Expand Down
100 changes: 97 additions & 3 deletions gateway/crates/pc-gateway-core/tests/executor_semantics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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, &[])),
Expand Down
23 changes: 16 additions & 7 deletions src/powercontext/builtin/runtime/context_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Handle invalid carriers independently of enforcement mode

With a configured target, malformed or duplicate-key JSON now reaches _validate_prepared() in disabled, shadow, and advisory modes, where uncaught parser exceptions produce HTTP 500. Validate carriers independently of privacy enforcement and translate parsing failures into a controlled invalid-carrier response.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 428172c. Request carrier parsing now translates ValueError/RecursionError at the parse_model_json boundary into InvalidRuntimeRequestError without exposing input content. HTTP regressions cover malformed JSON, duplicate keys, and non-object roots in disabled, shadow, and advisory modes; each returns 422 invalid_request rather than an uncaught server error.

return ContextCheckResult(
phase=request.phase,
decision=assessed.decision if assessed.decision != "allow" else "withhold",
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading