flowproof 0.4.1__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. {flowproof-0.4.1 → flowproof-0.5.0}/Cargo.lock +7 -7
  2. {flowproof-0.4.1 → flowproof-0.5.0}/Cargo.toml +1 -1
  3. {flowproof-0.4.1 → flowproof-0.5.0}/PKG-INFO +1 -1
  4. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-agent/src/recorder.rs +60 -4
  5. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-agent/src/rules.rs +5 -0
  6. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-agent/src/spec.rs +43 -8
  7. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/src/agent_flow.rs +36 -70
  8. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/src/lib.rs +373 -159
  9. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/tests/agent_flow_e2e.rs +18 -3
  10. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/tests/api_pipeline.rs +137 -0
  11. flowproof-0.5.0/crates/flowproof-cli/tests/audit_record_e2e.rs +218 -0
  12. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/tests/web_e2e.rs +172 -0
  13. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-driver/src/oob.rs +14 -9
  14. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-replay/src/lib.rs +118 -8
  15. flowproof-0.5.0/crates/flowproof-replay/src/runrecord.rs +633 -0
  16. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-trace/src/lib.rs +1 -0
  17. flowproof-0.5.0/crates/flowproof-trace/src/secret_scan.rs +190 -0
  18. {flowproof-0.4.1 → flowproof-0.5.0}/flowproof/__init__.py +1 -1
  19. {flowproof-0.4.1 → flowproof-0.5.0}/pyproject.toml +1 -1
  20. {flowproof-0.4.1 → flowproof-0.5.0}/README.md +0 -0
  21. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-adapters/Cargo.toml +0 -0
  22. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-adapters/src/agent_proxy.rs +0 -0
  23. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-adapters/src/agent_runner.rs +0 -0
  24. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-adapters/src/egress.rs +0 -0
  25. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-adapters/src/egress_linux.rs +0 -0
  26. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-adapters/src/lib.rs +0 -0
  27. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-adapters/src/mcp_core.rs +0 -0
  28. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-adapters/src/mcp_http.rs +0 -0
  29. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-adapters/src/mcp_stdio.rs +0 -0
  30. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-adapters/src/sap_com.rs +0 -0
  31. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-adapters/src/vision.rs +0 -0
  32. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-adapters/src/web.rs +0 -0
  33. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-agent/Cargo.toml +0 -0
  34. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-agent/src/agent_steps.rs +0 -0
  35. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-agent/src/author.rs +0 -0
  36. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-agent/src/clarify.rs +0 -0
  37. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-agent/src/heal.rs +0 -0
  38. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-agent/src/lib.rs +0 -0
  39. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-agent/src/llm.rs +0 -0
  40. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/Cargo.toml +0 -0
  41. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/src/capture.rs +0 -0
  42. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/src/main.rs +0 -0
  43. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/tests/calc_e2e.rs +0 -0
  44. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/tests/clock_e2e.rs +0 -0
  45. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/tests/egress_e2e.rs +0 -0
  46. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/tests/examples_resolve.rs +0 -0
  47. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/tests/grid_cell_e2e.rs +0 -0
  48. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/tests/llm_author_e2e.rs +0 -0
  49. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/tests/mcp_stdio_e2e.rs +0 -0
  50. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/tests/notepad_author_e2e.rs +0 -0
  51. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/tests/notepad_e2e.rs +0 -0
  52. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/tests/sap_e2e.rs +0 -0
  53. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/tests/sap_pipeline.rs +0 -0
  54. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/tests/sap_sim_e2e.rs +0 -0
  55. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/tests/scoped_container_e2e.rs +0 -0
  56. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/tests/skip_unless_env.rs +0 -0
  57. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/tests/suite_env_from.rs +0 -0
  58. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/tests/suite_flow_isolation.rs +0 -0
  59. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/tests/suite_missing_trace.rs +0 -0
  60. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/tests/support/sap_simulator.py +0 -0
  61. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-cli/tests/vision_pipeline.rs +0 -0
  62. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-driver/Cargo.toml +0 -0
  63. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-driver/src/app.rs +0 -0
  64. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-driver/src/backend.rs +0 -0
  65. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-driver/src/gdi.rs +0 -0
  66. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-driver/src/lib.rs +0 -0
  67. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-driver/src/mock.rs +0 -0
  68. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-driver/src/recording.rs +0 -0
  69. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-driver/src/redact.rs +0 -0
  70. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-driver/src/visual.rs +0 -0
  71. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-driver/src/window.rs +0 -0
  72. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-python/Cargo.toml +0 -0
  73. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-python/src/lib.rs +0 -0
  74. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-replay/Cargo.toml +0 -0
  75. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-replay/src/report.rs +0 -0
  76. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-replay/tests/replay_calc.rs +0 -0
  77. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-trace/Cargo.toml +0 -0
  78. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-trace/schema/trace-v1.schema.json +0 -0
  79. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-trace/src/cassette.rs +0 -0
  80. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-trace/src/cassette_diff.rs +0 -0
  81. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-trace/src/egress.rs +0 -0
  82. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-trace/src/format.rs +0 -0
  83. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-trace/src/secret.rs +0 -0
  84. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-trace/src/substitution.rs +0 -0
  85. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-trace/src/toolcalls.rs +0 -0
  86. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-trace/tests/fixtures/sample.trace.jsonl +0 -0
  87. {flowproof-0.4.1 → flowproof-0.5.0}/crates/flowproof-trace/tests/schema_conformance.rs +0 -0
  88. {flowproof-0.4.1 → flowproof-0.5.0}/flowproof/cli.py +0 -0
  89. {flowproof-0.4.1 → flowproof-0.5.0}/flowproof/flow.py +0 -0
  90. {flowproof-0.4.1 → flowproof-0.5.0}/flowproof/mcp_server.py +0 -0
  91. {flowproof-0.4.1 → flowproof-0.5.0}/flowproof/py.typed +0 -0
@@ -760,7 +760,7 @@ dependencies = [
760
760
 
761
761
  [[package]]
762
762
  name = "flowproof-adapters"
763
- version = "0.4.1"
763
+ version = "0.5.0"
764
764
  dependencies = [
765
765
  "anyhow",
766
766
  "flowproof-driver",
@@ -779,7 +779,7 @@ dependencies = [
779
779
 
780
780
  [[package]]
781
781
  name = "flowproof-agent"
782
- version = "0.4.1"
782
+ version = "0.5.0"
783
783
  dependencies = [
784
784
  "chrono",
785
785
  "flowproof-driver",
@@ -795,7 +795,7 @@ dependencies = [
795
795
 
796
796
  [[package]]
797
797
  name = "flowproof-cli"
798
- version = "0.4.1"
798
+ version = "0.5.0"
799
799
  dependencies = [
800
800
  "ab_glyph",
801
801
  "chrono",
@@ -816,7 +816,7 @@ dependencies = [
816
816
 
817
817
  [[package]]
818
818
  name = "flowproof-driver"
819
- version = "0.4.1"
819
+ version = "0.5.0"
820
820
  dependencies = [
821
821
  "image",
822
822
  "postgres",
@@ -830,7 +830,7 @@ dependencies = [
830
830
 
831
831
  [[package]]
832
832
  name = "flowproof-python"
833
- version = "0.4.1"
833
+ version = "0.5.0"
834
834
  dependencies = [
835
835
  "flowproof-agent",
836
836
  "flowproof-cli",
@@ -842,7 +842,7 @@ dependencies = [
842
842
 
843
843
  [[package]]
844
844
  name = "flowproof-replay"
845
- version = "0.4.1"
845
+ version = "0.5.0"
846
846
  dependencies = [
847
847
  "chrono",
848
848
  "flowproof-agent",
@@ -856,7 +856,7 @@ dependencies = [
856
856
 
857
857
  [[package]]
858
858
  name = "flowproof-trace"
859
- version = "0.4.1"
859
+ version = "0.5.0"
860
860
  dependencies = [
861
861
  "jsonschema",
862
862
  "regex",
@@ -3,7 +3,7 @@ resolver = "2"
3
3
  members = ["crates/flowproof-driver", "crates/flowproof-trace", "crates/flowproof-replay", "crates/flowproof-agent", "crates/flowproof-adapters", "crates/flowproof-cli", "crates/flowproof-python"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.4.1"
6
+ version = "0.5.0"
7
7
  edition = "2021"
8
8
  license = "Apache-2.0"
9
9
  repository = "https://github.com/automators-com/flowproof"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowproof
3
- Version: 0.4.1
3
+ Version: 0.5.0
4
4
  Classifier: Development Status :: 2 - Pre-Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Programming Language :: Python :: 3
@@ -63,6 +63,11 @@ pub enum RecordError {
63
63
  Driver(#[from] flowproof_driver::DriverError),
64
64
  #[error(transparent)]
65
65
  Secret(#[from] flowproof_trace::secret::MissingSecret),
66
+ /// A declared secret leaked into the run's observable corpus, or the
67
+ /// corpus could not be scanned (a too-short secret, a corpus-less flow
68
+ /// kind). Names variables and the corpus element, never the value.
69
+ #[error("{0}")]
70
+ SecretLeak(String),
66
71
  #[error("cannot write trace {path}: {source}")]
67
72
  Io {
68
73
  path: String,
@@ -1225,15 +1230,18 @@ fn web_browser_from_setup(
1225
1230
  /// Poll an out-of-band probe until it holds or the bound elapses — the row
1226
1231
  /// may still be committing, the API still converging. Configuration errors
1227
1232
  /// (missing connection env) fail immediately.
1233
+ /// Returns the probe's response body (an `Api` probe's response text, the
1234
+ /// corpus a secret-leak scan reads; `None` for `Sql`) once the expectation
1235
+ /// holds.
1228
1236
  fn poll_oob(
1229
1237
  probe: &flowproof_driver::oob::OobProbe,
1230
1238
  timeout_ms: u64,
1231
1239
  intent: &str,
1232
- ) -> Result<(), RecordError> {
1240
+ ) -> Result<Option<String>, RecordError> {
1233
1241
  let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms);
1234
1242
  loop {
1235
1243
  match flowproof_driver::oob::check(probe)? {
1236
- Ok(()) => return Ok(()),
1244
+ Ok(body) => return Ok(body),
1237
1245
  Err(reason) => {
1238
1246
  if std::time::Instant::now() >= deadline {
1239
1247
  return Err(RecordError::AssertMismatch {
@@ -1518,6 +1526,22 @@ pub fn record_with_reuse<D: AppDriver, C: ModelClient>(
1518
1526
  old_steps: Option<&[Step]>,
1519
1527
  ) -> Result<RecordSummary, RecordError> {
1520
1528
  let mut reuse = old_steps.map(ReuseCursor::new);
1529
+ // `assert_no_secret_leak` selectors, grouped by asserting step. The scan
1530
+ // is a whole-run store-guard: it runs on the in-memory corpus BEFORE the
1531
+ // trace is minted, so a leak fails the run and NO trace is written. A flow
1532
+ // that never uses the feature builds no corpus and behaves byte-for-byte
1533
+ // as before.
1534
+ let leak_assertions = spec.secret_leak_assertions();
1535
+ let scan_secrets = !leak_assertions.is_empty();
1536
+ // Honesty, not a vacuous pass: a flow kind with no readable corpus is
1537
+ // refused at execution (the same rule `assert_no_egress` follows), before
1538
+ // launching anything, so nothing is minted for a control that cannot be
1539
+ // checked. web and api are covered; agent never reaches this recorder.
1540
+ if scan_secrets && !flowproof_trace::secret_scan::has_readable_corpus(spec.app.id()) {
1541
+ return Err(RecordError::SecretLeak(
1542
+ flowproof_trace::secret_scan::capability_error(spec.app.id()),
1543
+ ));
1544
+ }
1521
1545
  let target = launch_target(spec)?;
1522
1546
  // The session is an inline mapping by now: a `session: <name>` ref is
1523
1547
  // dereferenced to its identity's inline setup at flow load, so record
@@ -1581,6 +1605,12 @@ pub fn record_with_reuse<D: AppDriver, C: ModelClient>(
1581
1605
  let mut captures: std::collections::HashMap<String, String> = std::collections::HashMap::new();
1582
1606
  let mut prior_intents: Vec<String> = Vec::new();
1583
1607
  let mut llm_used = false;
1608
+ // The secret-leak corpus, held in memory for this run only, exactly like a
1609
+ // resolved `${VAR}` value: (a) the web surface text read at each step
1610
+ // boundary (the same text `page shows` reads, not the page source) and
1611
+ // (b) every `assert_api` response body probed. Populated only when the
1612
+ // flow asserts `assert_no_secret_leak`.
1613
+ let mut secret_corpus: Vec<(String, String)> = Vec::new();
1584
1614
  for spec_step in &spec.steps {
1585
1615
  let intent = spec_step.intent().to_string();
1586
1616
  let actions = author_actions(
@@ -1714,7 +1744,7 @@ pub fn record_with_reuse<D: AppDriver, C: ModelClient>(
1714
1744
  None => None,
1715
1745
  },
1716
1746
  };
1717
- poll_oob(&probe, *timeout_ms, &spec_step.intent())?
1747
+ poll_oob(&probe, *timeout_ms, &spec_step.intent())?;
1718
1748
  }
1719
1749
  ResolvedAction::AssertApi {
1720
1750
  method,
@@ -1747,7 +1777,14 @@ pub fn record_with_reuse<D: AppDriver, C: ModelClient>(
1747
1777
  None => None,
1748
1778
  },
1749
1779
  };
1750
- poll_oob(&probe, *timeout_ms, &spec_step.intent())?
1780
+ let body = poll_oob(&probe, *timeout_ms, &spec_step.intent())?;
1781
+ // The response body joins the corpus a secret-leak scan
1782
+ // reads. Held in memory, never written to the trace.
1783
+ if scan_secrets {
1784
+ if let Some(text) = body {
1785
+ secret_corpus.push(("an assert_api response body".to_string(), text));
1786
+ }
1787
+ }
1751
1788
  }
1752
1789
  ResolvedAction::PressKey { key, modifiers } => {
1753
1790
  let mods: Vec<flowproof_driver::KeyMod> =
@@ -2063,6 +2100,25 @@ pub fn record_with_reuse<D: AppDriver, C: ModelClient>(
2063
2100
  }
2064
2101
  }
2065
2102
  }
2103
+ // Step boundary: sample the web surface text (the same text `page
2104
+ // shows` reads, not the page source). Per-step, not continuous: a
2105
+ // secret that flashes into the DOM between boundaries is invisible to
2106
+ // this control, an exclusion the audit output echoes.
2107
+ if scan_secrets && spec.app.id() == "web" {
2108
+ secret_corpus.push((
2109
+ "the surface text at a step boundary".to_string(),
2110
+ driver.surface_text()?,
2111
+ ));
2112
+ }
2113
+ }
2114
+
2115
+ // STORE-GUARD: scan the in-memory corpus BEFORE any trace is minted, by
2116
+ // the SAME mechanism replay uses. A leak (or a too-short secret, or a
2117
+ // corpus-less kind) fails the run here, so the leaked value never reaches
2118
+ // disk and no trace is written.
2119
+ if scan_secrets {
2120
+ flowproof_trace::secret_scan::scan_corpus(&leak_assertions, &secret_corpus)
2121
+ .map_err(RecordError::SecretLeak)?;
2066
2122
  }
2067
2123
 
2068
2124
  let recording = recorder.and_then(flowproof_driver::RunRecorder::finish);
@@ -471,6 +471,11 @@ fn resolve_step_inner(app: &str, step: &SpecStep) -> Result<Vec<ResolvedAction>,
471
471
  .map_or(ASSERT_TIMEOUT_MS, |s| s * 1000),
472
472
  }]);
473
473
  }
474
+ // A whole-run control assertion, not a per-step driver action: the
475
+ // record-time store-guard and the replay scan enforce it out of band
476
+ // against the captured corpus. It performs nothing on the UI, so it
477
+ // resolves to zero actions on every app kind.
478
+ SpecStep::AssertNoSecretLeak { .. } => return Ok(Vec::new()),
474
479
  SpecStep::AssertScreenshot { assert_screenshot } => {
475
480
  if assert_screenshot.name.trim().is_empty()
476
481
  || assert_screenshot.name.contains(['/', '\\'])
@@ -406,6 +406,11 @@ impl FlowSpec {
406
406
  let bad = |m: String| Err(SpecError::Agent(m));
407
407
  let is_agent = self.app.id() == "agent";
408
408
 
409
+ // `assert_no_secret_leak` is deliberately NOT here: v1 extends it to
410
+ // web and api flows, and any flow kind still without a readable corpus
411
+ // is refused at execution as a CAPABILITY error (never a vacuous
412
+ // parse-time rejection), the same honesty rule `assert_no_egress`
413
+ // follows at execution.
409
414
  let agent_only_step = self.steps.iter().find(|step| {
410
415
  matches!(
411
416
  step,
@@ -413,7 +418,6 @@ impl FlowSpec {
413
418
  | SpecStep::AssertToolCall { .. }
414
419
  | SpecStep::AssertNoToolCall { .. }
415
420
  | SpecStep::AssertNoEgress
416
- | SpecStep::AssertNoSecretLeak { .. }
417
421
  )
418
422
  });
419
423
 
@@ -816,6 +820,28 @@ impl FlowSpec {
816
820
  out
817
821
  }
818
822
 
823
+ /// The `assert_no_secret_leak` steps as corpus-scan assertions: each
824
+ /// step's `${VAR}` selectors paired with its 1-based position in `steps:`,
825
+ /// for the failure message. The single source both the record store-guard
826
+ /// and every replay build their scan from, so a web/api flow scans the
827
+ /// same names at both phases exactly as an agent flow does.
828
+ pub fn secret_leak_assertions(&self) -> Vec<flowproof_trace::secret_scan::LeakAssertion> {
829
+ self.steps
830
+ .iter()
831
+ .enumerate()
832
+ .filter_map(|(i, step)| match step {
833
+ SpecStep::AssertNoSecretLeak {
834
+ assert_no_secret_leak,
835
+ } => Some(flowproof_trace::secret_scan::LeakAssertion {
836
+ // 1-based, so the message reads as an author counts steps.
837
+ step_index: i + 1,
838
+ selectors: assert_no_secret_leak.clone(),
839
+ }),
840
+ _ => None,
841
+ })
842
+ .collect()
843
+ }
844
+
819
845
  /// Dereference a `session: <name>` ref against a suite's `identities:`
820
846
  /// map, replacing it with the named identity's inline setup - a
821
847
  /// LOAD-TIME COPY, so the trace stays self-contained (it carries the
@@ -2571,16 +2597,25 @@ mod security_spine_tests {
2571
2597
  }
2572
2598
  }
2573
2599
 
2574
- /// Agent-only in v1, exactly like `assert_no_egress`: the corpus this
2575
- /// lane scans is only wired for the model boundary today.
2600
+ /// Web and api flows now carry `assert_no_secret_leak`: v1 extends the
2601
+ /// corpus scan to their surface text and `assert_api` bodies, so the parse
2602
+ /// gate that once made it agent-only is gone. The honesty for corpus-less
2603
+ /// kinds moved to execution (a capability error), not parse.
2576
2604
  #[test]
2577
- fn assert_no_secret_leak_is_rejected_off_agent_flows() {
2578
- let err = spec(
2605
+ fn assert_no_secret_leak_parses_on_web_and_api_flows() {
2606
+ let web = spec(
2579
2607
  "name: n\napp: web\nurl: http://x\nsteps:\n - assert_no_secret_leak: ${DB_PASSWORD}\n",
2580
2608
  )
2581
- .expect_err("secret-leak on web");
2582
- assert!(err.to_string().contains("agent step"), "{err}");
2583
- assert!(err.to_string().contains("assert_no_secret_leak"), "{err}");
2609
+ .expect("secret-leak on web now parses");
2610
+ assert_eq!(
2611
+ web.secret_leak_assertions(),
2612
+ vec![flowproof_trace::secret_scan::LeakAssertion {
2613
+ step_index: 1,
2614
+ selectors: vec!["${DB_PASSWORD}".into()],
2615
+ }]
2616
+ );
2617
+ spec("name: n\napp: api\nsteps:\n - assert_no_secret_leak: ${API_TOKEN}\n")
2618
+ .expect("secret-leak on api now parses");
2584
2619
  }
2585
2620
 
2586
2621
  /// It round-trips through serialize/reparse: one selector as a string,
@@ -556,23 +556,10 @@ struct Plan {
556
556
  /// The `assert_no_secret_leak` steps, each naming one or more `${VAR}`
557
557
  /// selectors and its 1-based position in the flow's `steps:` (for the
558
558
  /// failure message). Only the variable NAMES travel here - never a value.
559
- secret_leaks: Vec<SecretLeakStep>,
559
+ /// The shared type web and api flows also build their scan from.
560
+ secret_leaks: Vec<flowproof_trace::secret_scan::LeakAssertion>,
560
561
  }
561
562
 
562
- /// One `assert_no_secret_leak` step: the `${VAR}` selectors it declares and
563
- /// its 1-based step position (named in the failure message).
564
- struct SecretLeakStep {
565
- step_index: usize,
566
- selectors: Vec<String>,
567
- }
568
-
569
- /// The smallest resolved secret length the corpus scan will accept.
570
- /// Scanning for a one- or two-character value would fire on almost any
571
- /// output (the doc's `"1"` example), so a secret shorter than this is
572
- /// REFUSED at execution rather than asserted imprecisely - a control that
573
- /// cannot be checked precisely is refused, not weakened.
574
- const MIN_SECRET_LEN: usize = 4;
575
-
576
563
  impl Plan {
577
564
  /// The port the proxy binds: the fixed `proxy_port` for an http driver,
578
565
  /// ephemeral (`0`) for a process.
@@ -691,21 +678,14 @@ fn plan(spec: &FlowSpec) -> Result<Plan, String> {
691
678
  let mut tool_calls = Vec::new();
692
679
  let mut forbidden = Vec::new();
693
680
  let mut reply_contains = Vec::new();
694
- let mut secret_leaks = Vec::new();
695
- for (i, step) in spec.steps.iter().enumerate() {
681
+ // The secret-leak assertions come from the shared spec accessor, the same
682
+ // source web and api flows build their scan from.
683
+ let secret_leaks = spec.secret_leak_assertions();
684
+ for step in spec.steps.iter() {
696
685
  match step {
697
686
  SpecStep::AssertToolCall { assert_tool_call } => {
698
687
  tool_calls.push(parse_expectation(assert_tool_call)?);
699
688
  }
700
- SpecStep::AssertNoSecretLeak {
701
- assert_no_secret_leak,
702
- } => {
703
- secret_leaks.push(SecretLeakStep {
704
- // 1-based, so the message reads as an author counts steps.
705
- step_index: i + 1,
706
- selectors: assert_no_secret_leak.clone(),
707
- });
708
- }
709
689
  SpecStep::AssertNoToolCall {
710
690
  assert_no_tool_call,
711
691
  } => {
@@ -767,6 +747,29 @@ pub fn engages_egress(spec: &FlowSpec) -> bool {
767
747
  declares_allow || asserts_no_egress
768
748
  }
769
749
 
750
+ /// The egress destinations containment DENIED during the recorded run, read
751
+ /// from the trace's egress lane as value-free `destination (protocol)`
752
+ /// descriptors. Empty for a flow with no egress lane, an unreadable trace, or
753
+ /// a run that attempted no undeclared egress - so it is safe to call on any
754
+ /// agent trace. Feeds the run record's `evidence.blocked`.
755
+ pub fn egress_blocked(trace_path: &Path) -> Vec<String> {
756
+ let Ok(raw) = std::fs::read_to_string(trace_path) else {
757
+ return Vec::new();
758
+ };
759
+ let Ok(trace) = serde_json::from_str::<AgentTrace>(&raw) else {
760
+ return Vec::new();
761
+ };
762
+ trace
763
+ .egress
764
+ .map(|e| {
765
+ e.blocked
766
+ .iter()
767
+ .map(|ev| format!("{} ({})", ev.destination, ev.protocol))
768
+ .collect()
769
+ })
770
+ .unwrap_or_default()
771
+ }
772
+
770
773
  /// The containment tier a flow's run achieves, on this platform. Pure and
771
774
  /// cheap (a kernel probe on Linux); computed both here, to print the report's
772
775
  /// tier line, and inside record/replay, to gate `assert_no_egress` and build
@@ -878,14 +881,12 @@ fn secret_corpus(
878
881
  corpus
879
882
  }
880
883
 
881
- /// Scan the run's captured corpus for any declared secret, by the SAME
882
- /// mechanism at record and replay, so an unchanged system yields the same
883
- /// verdict. Resolves each asserted `${VAR}` through the shared resolve-refs
884
- /// machinery and substring-scans the in-memory corpus; only variable NAMES
885
- /// are ever stored or printed. A leak names ALL matching variables in a
886
- /// stable order, the corpus element, and the step index - never the value.
887
- /// A resolved secret under the minimum length is refused (both phases), in
888
- /// the same shape as the shipped `MissingSecret` error.
884
+ /// Scan the run's captured corpus for any declared secret, by the SAME shared
885
+ /// mechanism web, api, and replay use, so an unchanged system yields the same
886
+ /// verdict. Builds the agent corpus (model-boundary trajectory + MCP lanes)
887
+ /// and delegates the resolve/refuse/substring-scan to
888
+ /// [`flowproof_trace::secret_scan::scan_corpus`]; only variable NAMES are ever
889
+ /// stored or printed.
889
890
  fn check_secret_leak(
890
891
  plan: &Plan,
891
892
  cassette: &Cassette,
@@ -895,42 +896,7 @@ fn check_secret_leak(
895
896
  return Ok(());
896
897
  }
897
898
  let corpus = secret_corpus(cassette, mcp);
898
- for leak in &plan.secret_leaks {
899
- // (selector, corpus element) for every variable that appeared,
900
- // collected then sorted so two leaks report in a stable order.
901
- let mut hits: Vec<(String, String)> = Vec::new();
902
- for selector in &leak.selectors {
903
- let value =
904
- flowproof_trace::secret::resolve_refs(selector).map_err(|e| e.to_string())?;
905
- if value.chars().count() < MIN_SECRET_LEN {
906
- // Named like MissingSecret: the variable and the minimum,
907
- // never the value.
908
- return Err(format!(
909
- "assert_no_secret_leak (step {}): {selector} resolves to a value shorter \
910
- than the {MIN_SECRET_LEN}-character minimum needed to scan for it \
911
- precisely; a secret that short cannot be asserted without false positives",
912
- leak.step_index
913
- ));
914
- }
915
- if let Some((element, _)) = corpus.iter().find(|(_, text)| text.contains(&value)) {
916
- hits.push((selector.clone(), element.clone()));
917
- }
918
- }
919
- if !hits.is_empty() {
920
- hits.sort();
921
- let list = hits
922
- .iter()
923
- .map(|(selector, element)| format!("{selector} in {element}"))
924
- .collect::<Vec<_>>()
925
- .join("; ");
926
- return Err(format!(
927
- "assert_no_secret_leak (step {}): a declared secret appeared in the run \
928
- output: {list}",
929
- leak.step_index
930
- ));
931
- }
932
- }
933
- Ok(())
899
+ flowproof_trace::secret_scan::scan_corpus(&plan.secret_leaks, &corpus)
934
900
  }
935
901
 
936
902
  fn parse_expectation(text: &str) -> Result<ToolCallExpectation, String> {