-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathprocess.rs
More file actions
4732 lines (4299 loc) · 165 KB
/
Copy pathprocess.rs
File metadata and controls
4732 lines (4299 loc) · 165 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#![allow(
clippy::expect_used,
clippy::panic,
reason = "this standalone integration-test crate uses assertion panics and explicit fixture expectations; the workspace gate remains active for production targets"
)]
use std::collections::BTreeMap;
use std::path::Path;
use std::time::Duration;
use schemars::JsonSchema;
use serde::Deserialize;
use signalbox_model_runtime::{
AssistantPart, CancellationSignal, CompletionFinish, ConversationMessage, ConversationRole,
CredentialReference, DeliveryMode, LossCause, MessagePart, ModelOperation, ModelRuntime,
Observation, ObservationFact, PreparationFailure, PreparationOutcome, ProviderErrorKind,
REDACTED, RequestedTarget, ResolvedTarget, StreamInterruption, StructuredDecodeFailure,
StructuredOutputContract, TerminalEvidence, TokenUsage, ToolCallId, ToolCallProposal,
ToolCallsAtLoss, ToolChoice, ToolDefinition, ToolName, decode_structured,
};
use signalbox_model_runtime_codex_cli::{
CodexCliConfig, CodexCliConstructionError, CodexCliRuntime,
DISABLED_CODEX_CLI_CAPABILITY_FEATURES,
};
use signalbox_test_bin::test_bin_path;
#[path = "support/fixtures.rs"]
mod fixtures;
const CREDENTIAL_REFERENCE: &str = "codex-subscription-primary";
const RESOLVED_TARGET: &str = "gpt-offline-exact";
const OFFLINE_HARNESS_TIMEOUT: Duration = Duration::from_secs(30);
/// Provider text carried by the terminal half of the boundary extractor fixture.
const BOUNDARY_FIXTURE_TERMINAL_TEXT: &str = "synthetic-terminal-boundary-text";
/// Provider text carried by the observation half of the boundary extractor fixture.
const BOUNDARY_FIXTURE_OBSERVATION_TEXT: &str = "synthetic-observation-boundary-text";
#[derive(Clone, Copy)]
enum OperationShape {
Text,
Tool,
Structured,
}
struct ExecutionResult {
evidence: TerminalEvidence,
observations: Vec<Observation<String>>,
spawns: usize,
argv: String,
prompt: String,
}
/// Flattens only the typed, provider-controlled strings that actually cross
/// the adapter boundary. Unlike a `Debug` dump, every included value is an
/// emitted observation or terminal-evidence field and every such field is
/// visited explicitly.
fn boundary_material(result: &ExecutionResult) -> String {
boundary_material_from(&result.evidence, &result.observations)
}
fn boundary_material_from(
evidence: &TerminalEvidence,
observations: &[Observation<String>],
) -> String {
let mut material = Vec::new();
collect_terminal_evidence(evidence, &mut material);
for observation in observations {
collect_observation(&observation.fact, &mut material);
}
material.join("\n")
}
fn collect_terminal_evidence(evidence: &TerminalEvidence, material: &mut Vec<String>) {
match evidence {
TerminalEvidence::Completed(completed) => {
collect_exchange(&completed.exchange, material);
collect_message_id(completed.message_id.as_ref(), material);
collect_reported_model(completed.reported_model.as_ref(), material);
collect_completion_finish(&completed.finish, material);
collect_assistant_parts(&completed.content, material);
}
TerminalEvidence::Refused(refused) => {
collect_exchange(&refused.exchange, material);
collect_message_id(refused.message_id.as_ref(), material);
collect_reported_model(refused.reported_model.as_ref(), material);
collect_assistant_parts(&refused.content, material);
}
TerminalEvidence::ProviderError(error) => {
collect_exchange(&error.exchange, material);
collect_reported_model(error.reported_model.as_ref(), material);
collect_native_error(&error.native, material);
}
TerminalEvidence::CancellationConfirmed(cancelled) => {
collect_exchange(&cancelled.exchange, material);
collect_reported_model(cancelled.reported_model.as_ref(), material);
collect_native_error(&cancelled.native, material);
}
TerminalEvidence::ProvenUnsent(unsent) => collect_unsent_cause(&unsent.cause, material),
TerminalEvidence::BoundaryLoss(loss) => {
collect_exchange(&loss.exchange, material);
collect_reported_model(loss.reported_model.as_ref(), material);
if let Some(finish) = &loss.finish_reported {
collect_finish_reason(finish, material);
}
collect_loss_cause(&loss.cause, material);
}
}
}
fn collect_observation(fact: &ObservationFact, material: &mut Vec<String>) {
match fact {
ObservationFact::SendCommenced | ObservationFact::UsageReported(_) => {}
ObservationFact::ExchangeEstablished(exchange) => collect_exchange(exchange, material),
ObservationFact::ProviderModelReported(model) => material.push(model.as_str().to_string()),
ObservationFact::TextDelta { text, .. } | ObservationFact::ThinkingDelta { text, .. } => {
material.push(text.clone())
}
ObservationFact::ToolArgumentsDelta { fragment, .. } => material.push(fragment.clone()),
ObservationFact::ToolCallProposed(proposal) => collect_tool_proposal(proposal, material),
ObservationFact::FinishReported(finish) => collect_finish_reason(finish, material),
}
}
fn collect_exchange(exchange: &signalbox_model_runtime::ExchangeFacts, material: &mut Vec<String>) {
if let Some(request_id) = &exchange.provider_request_id {
material.push(request_id.as_str().to_string());
}
}
fn collect_message_id(
message_id: Option<&signalbox_model_runtime::ProviderMessageId>,
material: &mut Vec<String>,
) {
if let Some(message_id) = message_id {
material.push(message_id.as_str().to_string());
}
}
fn collect_reported_model(
model: Option<&signalbox_model_runtime::ProviderReportedModel>,
material: &mut Vec<String>,
) {
if let Some(model) = model {
material.push(model.as_str().to_string());
}
}
fn collect_assistant_parts(parts: &[AssistantPart], material: &mut Vec<String>) {
for part in parts {
match part {
AssistantPart::Text(text) => material.push(text.clone()),
AssistantPart::Thinking { text, signature } => {
material.push(text.clone());
if let Some(signature) = signature {
material.push(signature.clone());
}
}
AssistantPart::RedactedThinking { data } => material.push(data.clone()),
AssistantPart::ToolCall(proposal) => collect_tool_proposal(proposal, material),
AssistantPart::SuppressedToolCall => {}
}
}
}
fn collect_tool_proposal(proposal: &ToolCallProposal, material: &mut Vec<String>) {
material.push(proposal.id.as_str().to_string());
material.push(proposal.name.as_str().to_string());
material.push(proposal.arguments_json.clone());
}
fn collect_native_error(
native: &signalbox_model_runtime::NativeErrorFacts,
material: &mut Vec<String>,
) {
material.extend(native.error_token.iter().cloned());
material.extend(native.error_code.iter().cloned());
material.extend(native.message.iter().cloned());
}
fn collect_completion_finish(finish: &CompletionFinish, material: &mut Vec<String>) {
match finish {
CompletionFinish::StopSequence { sequence } => material.extend(sequence.iter().cloned()),
CompletionFinish::Unrecognized { provider_token } => {
material.push(provider_token.clone());
}
CompletionFinish::EndTurn
| CompletionFinish::MaxOutputTokens
| CompletionFinish::ContextWindowExceeded
| CompletionFinish::ToolUse => {}
}
}
fn collect_finish_reason(
finish: &signalbox_model_runtime::FinishReason,
material: &mut Vec<String>,
) {
match finish {
signalbox_model_runtime::FinishReason::StopSequence { sequence } => {
material.extend(sequence.iter().cloned());
}
signalbox_model_runtime::FinishReason::Unrecognized { provider_token } => {
material.push(provider_token.clone());
}
signalbox_model_runtime::FinishReason::EndTurn
| signalbox_model_runtime::FinishReason::MaxOutputTokens
| signalbox_model_runtime::FinishReason::ContextWindowExceeded
| signalbox_model_runtime::FinishReason::ToolUse
| signalbox_model_runtime::FinishReason::Refusal => {}
}
}
fn collect_unsent_cause(cause: &signalbox_model_runtime::UnsentCause, material: &mut Vec<String>) {
match cause {
signalbox_model_runtime::UnsentCause::CancelledBeforeSend => {}
signalbox_model_runtime::UnsentCause::ConnectFailed(facts)
| signalbox_model_runtime::UnsentCause::SendIncompleteProvenUnacceptable(facts) => {
material.push(facts.detail.clone());
}
}
}
fn collect_loss_cause(cause: &LossCause, material: &mut Vec<String>) {
match cause {
LossCause::CancellationRequested
| LossCause::UnexpectedHttpStatus
| LossCause::StreamEndedWithoutTerminalMarker {
interruption: StreamInterruption::EndOfStream,
} => {}
LossCause::TimedOut(facts)
| LossCause::TransportFailed(facts)
| LossCause::ResponseBodyLost(facts)
| LossCause::StreamEndedWithoutTerminalMarker {
interruption:
StreamInterruption::TransportFailure(facts) | StreamInterruption::TimedOut(facts),
} => material.push(facts.detail.clone()),
LossCause::ResponseUnintelligible { detail }
| LossCause::StreamProtocolViolation { detail } => material.push(detail.clone()),
}
}
/// Which of a correlation's parallel delta streams a fragment extends. Part of
/// the reassembly key: two facts may share a correlation and an index yet
/// belong to streams no consumer ever concatenates together.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
enum EmittedStream {
Text,
Thinking,
ToolArguments,
}
/// Reassembles each delta stream the adapter emitted, in emission order,
/// grouped by the stream a fragment extends — correlation, kind, and index.
///
/// [`boundary_material`] joins the facts it visits with newlines, which is what
/// a *field*-level claim needs but not what a stream-level one does: the
/// redactor may emit a safe prefix and its continuation as two fragments of one
/// delta stream, and a credential spanning that split is recoverable by any
/// consumer that concatenates the stream while appearing in no single fragment
/// and in no newline-joined dump. A claim about what the caller can read is
/// therefore checked against the reconstruction the caller assembles.
///
/// Exhaustive over `ObservationFact` so a new text-bearing fact cannot be added
/// without deciding whether it joins a stream. Facts that carry one complete
/// value rather than a fragment (a decoded tool proposal, the thread id) are
/// not fragments of anything and stay covered by [`boundary_material`].
fn emitted_streams(observations: &[Observation<String>]) -> Vec<String> {
let mut streams: BTreeMap<(&str, EmittedStream, u32), String> = BTreeMap::new();
for observation in observations {
let correlation = observation.correlation.as_str();
let (kind, index, fragment) = match &observation.fact {
ObservationFact::TextDelta { index, text } => (EmittedStream::Text, *index, text),
ObservationFact::ThinkingDelta { index, text } => {
(EmittedStream::Thinking, *index, text)
}
ObservationFact::ToolArgumentsDelta { index, fragment } => {
(EmittedStream::ToolArguments, *index, fragment)
}
ObservationFact::SendCommenced
| ObservationFact::ExchangeEstablished(_)
| ObservationFact::ProviderModelReported(_)
| ObservationFact::ToolCallProposed(_)
| ObservationFact::UsageReported(_)
| ObservationFact::FinishReported(_) => continue,
};
streams
.entry((correlation, kind, index))
.or_default()
.push_str(fragment);
}
streams.into_values().collect()
}
/// Asserts `secret` is unrecoverable from everything that crossed the adapter
/// boundary: every field [`boundary_material`] visits, and every delta stream
/// reassembled as the caller would read it. `label` names the fixture, so a
/// leak in one case of a multi-scenario test is reported as itself.
///
/// Both checks, because they fail differently. Every fixture this helper guards
/// is suppressed whole today and emits one fragment, so the field-level dump
/// alone would catch each of them; the reassembly is what keeps that from being
/// the assertion's ceiling. A regression that released the safe prefix and held
/// only the tail would put the credential in no single fragment and in no
/// newline-joined dump, and the case guarding the leak would go on passing.
#[track_caller]
fn assert_no_emitted_stream_carries(label: &str, result: &ExecutionResult, secret: &str) {
let material = boundary_material(result);
assert!(
!material.contains(secret),
"{label}: the credential must not cross the boundary, found it in: {material}"
);
for stream in emitted_streams(&result.observations) {
assert!(
!stream.contains(secret),
"{label}: the credential must not be recoverable from a reassembled stream: {stream}"
);
}
}
#[test]
fn boundary_material_reads_terminal_and_observation_text() {
let result = ExecutionResult {
evidence: TerminalEvidence::BoundaryLoss(signalbox_model_runtime::BoundaryLossEvidence {
cause: LossCause::StreamProtocolViolation {
detail: BOUNDARY_FIXTURE_TERMINAL_TEXT.to_string(),
},
exchange: signalbox_model_runtime::ExchangeFacts::default(),
reported_model: None,
finish_reported: None,
tool_calls: signalbox_model_runtime::ToolCallsAtLoss::Unobserved,
usage: TokenUsage::unreported(),
}),
observations: vec![Observation {
correlation: "boundary-fixture".to_string(),
fact: ObservationFact::TextDelta {
index: 0,
text: BOUNDARY_FIXTURE_OBSERVATION_TEXT.to_string(),
},
}],
spawns: 0,
argv: String::new(),
prompt: String::new(),
};
let material = boundary_material(&result);
assert!(material.contains(BOUNDARY_FIXTURE_TERMINAL_TEXT));
assert!(material.contains(BOUNDARY_FIXTURE_OBSERVATION_TEXT));
}
fn text_delta_fixture(correlation: &str, index: u32, text: &str) -> Observation<String> {
Observation {
correlation: correlation.to_string(),
fact: ObservationFact::TextDelta {
index,
text: text.to_string(),
},
}
}
/// The reassembly every stream-level absence check depends on: a value the
/// redactor emitted as two fragments of one stream is one reconstruction, so a
/// credential spanning the split cannot hide in the gap between them.
#[test]
fn emitted_streams_rejoin_the_fragments_of_one_stream() {
let observations = vec![
text_delta_fixture("reassembly-fixture", 0, "synthetic-reassembly-"),
text_delta_fixture("reassembly-fixture", 0, "secret"),
];
assert_eq!(
emitted_streams(&observations),
vec!["synthetic-reassembly-secret".to_string()]
);
}
/// Grouping is per stream: fragments that spell a value only across streams no
/// consumer concatenates — a different part index, a different correlation —
/// are separate reconstructions, so the check does not report a leak the caller
/// cannot read.
#[test]
fn emitted_streams_keep_separate_streams_apart() {
let observations = vec![
text_delta_fixture("reassembly-fixture", 0, "synthetic-reassembly-"),
text_delta_fixture("reassembly-fixture", 1, "secret"),
text_delta_fixture("other-correlation", 0, "secret"),
];
// Three reconstructions, none of them the joined value: the fragments stay
// in the streams that carried them, keyed correlation-first.
assert_eq!(
emitted_streams(&observations),
vec![
"secret".to_string(),
"synthetic-reassembly-".to_string(),
"secret".to_string(),
]
);
}
#[derive(Debug, Deserialize, JsonSchema, PartialEq)]
struct Verdict {
accepted: bool,
}
/// INV-025, INV-026: one completed call crosses exactly one process-spawn
/// dispatch boundary.
#[tokio::test]
async fn buffered_completion_is_terminal_only_after_turn_completed() {
let scenario = "buffered_completed";
let result = execute_scenario(
scenario,
DeliveryMode::Buffered,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let completed = completed(&result.evidence);
assert_eq!(completed.finish, CompletionFinish::EndTurn);
assert_eq!(
completed.content,
vec![AssistantPart::Text(fixtures::BUFFERED_ANSWER.to_string())]
);
assert_eq!(
completed
.exchange
.provider_request_id
.as_ref()
.map(signalbox_model_runtime::ProviderRequestId::as_str),
Some(fixtures::THREAD_ID)
);
assert_eq!(
completed.usage,
TokenUsage {
input_tokens: Some(fixtures::INPUT_TOKENS),
output_tokens: Some(fixtures::OUTPUT_TOKENS),
cache_creation_input_tokens: Some(fixtures::CACHE_CREATION_INPUT_TOKENS),
cache_read_input_tokens: Some(fixtures::CACHE_READ_INPUT_TOKENS),
}
);
assert_eq!(result.spawns, 1);
assert!(result.argv.contains("exec\n--json\n--ephemeral"));
assert!(result.argv.contains("--ignore-user-config"));
assert!(result.argv.contains("--ignore-rules"));
assert!(result.argv.contains(&disabled_capability_argv()));
assert!(result.argv.contains("--config\nagents.enabled=false"));
assert!(
result
.argv
.contains("--config\nskills.include_instructions=false")
);
assert!(result.argv.contains("--config\nmcp_servers={}"));
assert!(result.argv.contains("--config\nweb_search=\"disabled\""));
assert!(result.argv.contains("--config\nproject_doc_max_bytes=0"));
assert!(result.argv.contains(RESOLVED_TARGET));
assert!(result.prompt.contains(scenario));
}
/// Exact ordered argv fragment generated from the audited production fixture,
/// so the process regression proves every classified capability reaches the
/// spawned CLI as a hard disable without re-encoding the list in the test.
fn disabled_capability_argv() -> String {
DISABLED_CODEX_CLI_CAPABILITY_FEATURES
.iter()
.map(|feature| format!("--disable\n{feature}"))
.collect::<Vec<_>>()
.join("\n")
}
#[tokio::test]
async fn streamed_completion_emits_redacted_progress_in_order() {
let result = execute_scenario(
"streamed_completed",
DeliveryMode::Streamed,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let completed = completed(&result.evidence);
assert_eq!(
completed.content,
vec![AssistantPart::Text(fixtures::STREAMED_ANSWER.to_string())]
);
assert!(result.observations.iter().any(|observation| {
observation.fact
== ObservationFact::TextDelta {
index: 1,
text: fixtures::STREAMED_ANSWER.to_string(),
}
}));
assert!(result.observations.iter().any(|observation| {
observation.fact
== ObservationFact::ThinkingDelta {
index: 0,
text: fixtures::REASONING_TEXT.to_string(),
}
}));
assert_eq!(result.spawns, 1);
}
/// INV-035: a credential token split across reasoning items cannot be
/// reconstructed by concatenating streamed provider text.
#[tokio::test]
async fn inv_035_split_credential_across_reasoning_items_is_redacted() {
let result = execute_scenario(
"split_stream_credential_between_reasoning_items",
DeliveryMode::Streamed,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let streamed = streamed_provider_text(&result.observations);
assert!(!streamed.contains(fixtures::SENSITIVE_SPLIT_STREAM_TOKEN));
assert!(streamed.contains("[redacted]"));
assert_eq!(result.spawns, 1);
}
/// INV-035: changing from reasoning to final text cannot flush a held
/// credential prefix as provider-controlled bytes.
#[tokio::test]
async fn inv_035_split_credential_before_final_text_is_redacted() {
let result = execute_scenario(
"split_stream_credential_before_final_text",
DeliveryMode::Streamed,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let streamed = streamed_provider_text(&result.observations);
assert!(!streamed.contains(fixtures::SENSITIVE_SPLIT_STREAM_TOKEN));
assert!(streamed.contains("[redacted]"));
assert!(result.observations.iter().any(|observation| {
observation.fact
== ObservationFact::TextDelta {
index: 1,
text: "[redacted]".to_string(),
}
}));
assert_eq!(result.spawns, 1);
}
/// INV-035: a credential header split between reasoning and final text keeps
/// redacting through the value, not just through the marker.
#[tokio::test]
async fn inv_035_split_authorization_value_before_final_text_is_redacted() {
let result = execute_scenario(
"split_stream_authorization_before_final_text",
DeliveryMode::Streamed,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let streamed = streamed_provider_text(&result.observations);
assert!(!streamed.contains(fixtures::SENSITIVE_SPLIT_AUTHORIZATION));
assert!(streamed.contains("[redacted]"));
assert_eq!(
completed(&result.evidence).content,
vec![AssistantPart::Text("[redacted]".to_string())],
"terminal completion content must carry the stateful stream redaction"
);
assert_eq!(result.spawns, 1);
}
/// INV-035: buffered delivery drops reasoning from the output, but a
/// credential marker inside the dropped bytes still marks the final text's
/// value as a secret — the same bytes the streamed path suppresses must not
/// surface verbatim in buffered completion evidence.
#[tokio::test]
async fn inv_035_buffered_reasoning_marker_suppresses_the_final_text_value() {
let result = execute_scenario(
"split_stream_authorization_before_final_text",
DeliveryMode::Buffered,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let diagnostic = boundary_material(&result);
assert!(!diagnostic.contains(fixtures::SENSITIVE_SPLIT_AUTHORIZATION));
assert!(diagnostic.contains("[redacted]"));
assert_eq!(result.spawns, 1);
}
/// INV-035: the dropped-reasoning marker also governs buffered tool
/// arguments, which reach terminal evidence without passing through streamed
/// deltas.
#[tokio::test]
async fn inv_035_buffered_reasoning_marker_suppresses_tool_arguments() {
let result = execute_scenario(
"split_stream_authorization_before_tool_arguments",
DeliveryMode::Buffered,
OperationShape::Tool,
CancellationSignal::never(),
)
.await;
let diagnostic = boundary_material(&result);
assert!(!diagnostic.contains(fixtures::SENSITIVE_SPLIT_AUTHORIZATION));
assert!(diagnostic.contains("[redacted]"));
assert_eq!(
completed(&result.evidence).content,
vec![
AssistantPart::Text(REDACTED.to_string()),
AssistantPart::SuppressedToolCall,
]
);
assert_eq!(result.spawns, 1);
}
/// INV-035: a credential header split between streamed reasoning and the
/// final envelope's tool arguments keeps redacting through the value: the
/// argument bytes consult the held lookbehind state before the streamed
/// argument delta and the terminal proposal are built.
#[tokio::test]
async fn inv_035_split_authorization_value_before_tool_arguments_is_redacted() {
let result = execute_scenario(
"split_stream_authorization_before_tool_arguments",
DeliveryMode::Streamed,
OperationShape::Tool,
CancellationSignal::never(),
)
.await;
let diagnostic = boundary_material(&result);
assert!(!diagnostic.contains(fixtures::SENSITIVE_SPLIT_AUTHORIZATION));
assert!(diagnostic.contains("[redacted]"));
assert_eq!(result.spawns, 1);
}
/// INV-035: a credential marker held from streamed reasoning also governs a
/// tool-call id, so an id that extends the marker is replaced with a safe
/// surrogate instead of leaking through the proposal or terminal content.
#[tokio::test]
async fn inv_035_split_authorization_value_before_tool_id_is_redacted() {
let result = execute_scenario(
"split_stream_authorization_before_tool_id",
DeliveryMode::Streamed,
OperationShape::Tool,
CancellationSignal::never(),
)
.await;
let diagnostic = boundary_material(&result);
assert!(!diagnostic.contains(fixtures::SENSITIVE_SPLIT_AUTHORIZATION));
assert!(diagnostic.contains("[redacted]"));
assert_eq!(result.spawns, 1);
}
/// INV-035: a tool argument continuing a credential marker at the end of the
/// same-envelope final text is redacted in both the streamed delta and the
/// terminal proposal, not only when the marker came from earlier reasoning.
#[tokio::test]
async fn inv_035_final_text_marker_before_tool_arguments_is_redacted() {
let result = execute_scenario(
"final_text_marker_before_tool_arguments",
DeliveryMode::Streamed,
OperationShape::Tool,
CancellationSignal::never(),
)
.await;
let diagnostic = boundary_material(&result);
assert!(!diagnostic.contains(fixtures::SENSITIVE_SPLIT_AUTHORIZATION));
assert!(diagnostic.contains("[redacted]"));
assert_eq!(result.spawns, 1);
}
/// INV-035: a credential marker ending the final envelope text also governs
/// the agent-message item id — the same same-envelope context the tool-call id
/// path consults — so an id carrying the marker's continuation never surfaces
/// as `ProviderMessageId` beside the independently redacted text.
#[tokio::test]
async fn inv_035_final_text_marker_before_message_id_is_redacted() {
let result = execute_scenario(
"final_text_marker_before_message_id",
DeliveryMode::Streamed,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let diagnostic = boundary_material(&result);
assert!(!diagnostic.contains(fixtures::SENSITIVE_SPLIT_AUTHORIZATION));
assert!(diagnostic.contains("[redacted]"));
assert_eq!(result.spawns, 1);
}
/// INV-035: a thread id ending in a credential-marker prefix (`api_`) seeds
/// the lookbehind when it is emitted in `ExchangeEstablished`, so streamed
/// text carrying the marker's continuation (`key=value`) is suppressed
/// instead of emitted beside the id, where the two records would reconstruct
/// the credential.
#[tokio::test]
async fn inv_035_thread_id_marker_prefix_suppresses_streamed_continuation() {
let result = execute_scenario(
"credential_prefix_thread_id_before_text",
DeliveryMode::Streamed,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let diagnostic = boundary_material(&result);
assert!(!diagnostic.contains(fixtures::SENSITIVE_THREAD_CONTINUATION));
assert!(!diagnostic.contains("opaque-thread-continuation"));
// The id itself is harmless alone and keeps its diagnostic fidelity; the
// suppression lands on the continuation text, not the exchange facts.
assert!(diagnostic.contains(fixtures::CREDENTIAL_PREFIX_THREAD_ID));
assert_eq!(result.spawns, 1);
}
/// INV-035: the same reconstruction is caught in buffered delivery, where the
/// final text reaches terminal evidence without passing through streamed
/// deltas — the buffered text consults the emitted thread-id context too.
#[tokio::test]
async fn inv_035_thread_id_marker_prefix_suppresses_buffered_continuation() {
let result = execute_scenario(
"credential_prefix_thread_id_before_text",
DeliveryMode::Buffered,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let diagnostic = boundary_material(&result);
assert!(!diagnostic.contains(fixtures::SENSITIVE_THREAD_CONTINUATION));
assert!(!diagnostic.contains("opaque-thread-continuation"));
assert_eq!(result.spawns, 1);
}
/// INV-035: a credential marker inside a dropped error item governs the
/// streamed final text that follows — the marker appears in no record, but
/// the value completing it is a secret the stream must suppress.
#[tokio::test]
async fn inv_035_error_item_marker_suppresses_streamed_continuation() {
let result = execute_scenario(
"credential_split_across_error_item",
DeliveryMode::Streamed,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let diagnostic = boundary_material(&result);
assert!(!diagnostic.contains(fixtures::SENSITIVE_SPLIT_AUTHORIZATION));
assert!(diagnostic.contains("[redacted]"));
assert_eq!(result.spawns, 1);
}
/// INV-035: two independent object fields each ending in a distinct credential
/// marker fail closed — a following value could complete either, and the
/// single dropped chain cannot track both.
#[tokio::test]
async fn inv_035_two_independent_sibling_markers_fail_closed() {
let result = execute_scenario(
"two_independent_sibling_markers",
DeliveryMode::Buffered,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let diagnostic = boundary_material(&result);
assert!(!diagnostic.contains(fixtures::SENSITIVE_SPLIT_AUTHORIZATION));
assert!(diagnostic.contains("[redacted]"));
assert_eq!(result.spawns, 1);
}
/// INV-035: an additive credential marker on a `thread.started` event governs
/// the following final text.
#[tokio::test]
async fn inv_035_thread_started_additive_field_marker_suppresses_the_value() {
let result = execute_scenario(
"credential_split_across_thread_started_field",
DeliveryMode::Buffered,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let diagnostic = boundary_material(&result);
assert!(!diagnostic.contains(fixtures::SENSITIVE_SPLIT_AUTHORIZATION));
assert!(diagnostic.contains("[redacted]"));
assert_eq!(result.spawns, 1);
}
/// A streamed completion whose final text is empty and whose only provisional
/// content was a held-credential `[redacted]` placeholder — replaced by an
/// empty capture — fails closed as ResponseUnintelligible, not a contentless
/// Completed.
#[tokio::test]
async fn streamed_empty_completion_with_held_credential_is_unintelligible() {
let result = execute_scenario(
"streamed_empty_final_text_with_held_credential",
DeliveryMode::Streamed,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let cause = response_unintelligible(&boundary_loss(&result.evidence).cause);
assert!(cause.contains("no completion material"));
assert_eq!(
boundary_loss(&result.evidence).finish_reported,
Some(signalbox_model_runtime::FinishReason::EndTurn)
);
}
/// Repeated members are ambiguous stream input, never additive evolution: the
/// adapter fails closed before a last-value-wins JSON projection can choose
/// which occurrence becomes evidence.
#[tokio::test]
async fn duplicate_event_members_are_stream_protocol_violations() {
let result = execute_scenario(
"duplicate_unknown_event_member",
DeliveryMode::Buffered,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let detail = stream_protocol_violation(&boundary_loss(&result.evidence).cause);
assert!(detail.contains("duplicate"));
}
/// Repeated members stay ambiguous at nested object depth; the validation walk
/// cannot stop at the event envelope before serde projects its child objects.
#[tokio::test]
async fn nested_duplicate_event_members_are_stream_protocol_violations() {
let result = execute_scenario(
"nested_duplicate_unknown_event_member",
DeliveryMode::Buffered,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let detail = stream_protocol_violation(&boundary_loss(&result.evidence).cause);
assert!(detail.contains("duplicate"));
}
/// The response envelope is provider input even though the CLI transports it as
/// escaped agent-message text; repeated envelope members remain ambiguous and
/// must fail before serde can select the last occurrence.
#[tokio::test]
async fn duplicate_response_envelope_members_are_stream_protocol_violations() {
let result = execute_scenario(
"duplicate_response_envelope_member",
DeliveryMode::Buffered,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let detail = stream_protocol_violation(&boundary_loss(&result.evidence).cause);
assert!(detail.contains("response envelope"));
}
/// INV-035: a marker-bearing object field that sorts before a benign sibling
/// (so a key-sorted concatenation would drop the marker) still governs the
/// following final text — sibling fields are seeded as independent units.
#[tokio::test]
async fn inv_035_sibling_object_field_marker_is_not_erased() {
let result = execute_scenario(
"credential_split_across_sibling_object_fields",
DeliveryMode::Buffered,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let diagnostic = boundary_material(&result);
assert!(!diagnostic.contains(fixtures::SENSITIVE_SPLIT_AUTHORIZATION));
assert!(diagnostic.contains("[redacted]"));
assert_eq!(result.spawns, 1);
}
/// INV-035: an additive credential marker on an otherwise-accepted
/// `turn.started` event governs the following final text.
#[tokio::test]
async fn inv_035_turn_started_additive_field_marker_suppresses_the_value() {
let result = execute_scenario(
"credential_split_across_turn_started_field",
DeliveryMode::Buffered,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let diagnostic = boundary_material(&result);
assert!(!diagnostic.contains(fixtures::SENSITIVE_SPLIT_AUTHORIZATION));
assert!(diagnostic.contains("[redacted]"));
assert_eq!(result.spawns, 1);
}
/// INV-035: a retained agent message ending in a marker, superseded by a
/// `turn.failed` whose message supplies the value, is folded so the failure
/// message's value is suppressed in native error evidence.
#[tokio::test]
async fn inv_035_retained_agent_message_folds_before_failure() {
let result = execute_scenario(
"credential_split_across_agent_message_then_failure",
DeliveryMode::Buffered,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let diagnostic = boundary_material(&result);
assert!(!diagnostic.contains(fixtures::SENSITIVE_SPLIT_AUTHORIZATION));
assert!(diagnostic.contains("[redacted]"));
assert_eq!(result.spawns, 1);
}
/// INV-035: a credential marker ending an agent message superseded by a later
/// one, with the value in the final message, is folded into the lookbehind and
/// suppressed rather than reconstructed across the discard.
#[tokio::test]
async fn inv_035_superseded_agent_message_marker_suppresses_the_value() {
let result = execute_scenario(
"credential_split_across_superseded_agent_message",
DeliveryMode::Buffered,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let diagnostic = boundary_material(&result);
assert!(!diagnostic.contains(fixtures::SENSITIVE_SPLIT_AUTHORIZATION));
assert!(diagnostic.contains("[redacted]"));
assert_eq!(result.spawns, 1);
}
/// INV-035: a credential marker carried by an ignored lifecycle event's
/// additive field governs the following final text.
#[tokio::test]
async fn inv_035_lifecycle_event_marker_suppresses_the_value() {
let result = execute_scenario(
"credential_split_across_lifecycle_event",
DeliveryMode::Buffered,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let diagnostic = boundary_material(&result);
assert!(!diagnostic.contains(fixtures::SENSITIVE_SPLIT_AUTHORIZATION));
assert!(diagnostic.contains("[redacted]"));
assert_eq!(result.spawns, 1);
}
/// INV-035: a credential marker in an additively-tolerated unknown top-level
/// event governs the following final text.
#[tokio::test]
async fn inv_035_unknown_event_marker_suppresses_the_value() {
let result = execute_scenario(
"credential_split_across_unknown_event",
DeliveryMode::Buffered,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let diagnostic = boundary_material(&result);
assert!(!diagnostic.contains(fixtures::SENSITIVE_SPLIT_AUTHORIZATION));
assert!(diagnostic.contains("[redacted]"));
assert_eq!(result.spawns, 1);
}
/// INV-035: an unmodeled item's ordered-array leaves that jointly form a
/// marker (`["api", "_key="]`) seed the lookbehind in document order, so the
/// following value is suppressed even though no single leaf is a marker.
#[tokio::test]
async fn inv_035_ordered_unsupported_leaves_form_a_marker() {
let result = execute_scenario(
"credential_split_across_ordered_unsupported_leaves",
DeliveryMode::Buffered,
OperationShape::Text,
CancellationSignal::never(),
)
.await;
let diagnostic = boundary_material(&result);
assert!(!diagnostic.contains(fixtures::SENSITIVE_SPLIT_AUTHORIZATION));
assert!(diagnostic.contains("[redacted]"));
assert_eq!(result.spawns, 1);
}
/// INV-035: an agent-message id ending in a credential-marker prefix whose
/// continuation opens the final text is redacted, breaking the credential
/// reconstruction across the id and content fields of terminal evidence.
#[tokio::test]
async fn inv_035_message_id_prefixing_final_text_is_redacted() {
let result = execute_scenario(
"message_id_prefixes_final_text",
DeliveryMode::Buffered,
OperationShape::Text,