flowproof 0.7.0__tar.gz → 0.8.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 (95) hide show
  1. {flowproof-0.7.0 → flowproof-0.8.0}/Cargo.lock +7 -7
  2. {flowproof-0.7.0 → flowproof-0.8.0}/Cargo.toml +1 -1
  3. {flowproof-0.7.0 → flowproof-0.8.0}/PKG-INFO +1 -1
  4. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-adapters/src/agent_proxy.rs +67 -5
  5. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-adapters/src/egress_linux.rs +91 -13
  6. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-adapters/src/mcp_stdio.rs +44 -2
  7. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/src/agent_flow.rs +372 -14
  8. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/src/lib.rs +24 -0
  9. flowproof-0.8.0/crates/flowproof-cli/tests/doctor_e2e.rs +83 -0
  10. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-trace/src/cassette.rs +308 -56
  11. {flowproof-0.7.0 → flowproof-0.8.0}/flowproof/__init__.py +1 -1
  12. {flowproof-0.7.0 → flowproof-0.8.0}/pyproject.toml +1 -1
  13. {flowproof-0.7.0 → flowproof-0.8.0}/README.md +0 -0
  14. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-adapters/Cargo.toml +0 -0
  15. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-adapters/src/agent_runner.rs +0 -0
  16. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-adapters/src/egress.rs +0 -0
  17. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-adapters/src/lib.rs +0 -0
  18. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-adapters/src/mcp_core.rs +0 -0
  19. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-adapters/src/mcp_http.rs +0 -0
  20. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-adapters/src/sap_com.rs +0 -0
  21. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-adapters/src/vision.rs +0 -0
  22. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-adapters/src/web.rs +0 -0
  23. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-agent/Cargo.toml +0 -0
  24. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-agent/src/agent_steps.rs +0 -0
  25. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-agent/src/author.rs +0 -0
  26. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-agent/src/clarify.rs +0 -0
  27. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-agent/src/heal.rs +0 -0
  28. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-agent/src/lib.rs +0 -0
  29. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-agent/src/llm.rs +0 -0
  30. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-agent/src/recorder.rs +0 -0
  31. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-agent/src/rules.rs +0 -0
  32. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-agent/src/spec.rs +0 -0
  33. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/Cargo.toml +0 -0
  34. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/src/capture.rs +0 -0
  35. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/src/main.rs +0 -0
  36. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/agent_flow_e2e.rs +0 -0
  37. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/api_pipeline.rs +0 -0
  38. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/audit_record_e2e.rs +0 -0
  39. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/calc_e2e.rs +0 -0
  40. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/clock_e2e.rs +0 -0
  41. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/cookie_e2e.rs +0 -0
  42. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/egress_e2e.rs +0 -0
  43. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/examples_resolve.rs +0 -0
  44. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/grid_cell_e2e.rs +0 -0
  45. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/iframe_e2e.rs +0 -0
  46. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/llm_author_e2e.rs +0 -0
  47. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/mcp_stdio_e2e.rs +0 -0
  48. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/notepad_author_e2e.rs +0 -0
  49. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/notepad_e2e.rs +0 -0
  50. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/page_title_e2e.rs +0 -0
  51. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/sap_e2e.rs +0 -0
  52. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/sap_pipeline.rs +0 -0
  53. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/sap_sim_e2e.rs +0 -0
  54. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/scoped_container_e2e.rs +0 -0
  55. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/skip_unless_env.rs +0 -0
  56. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/suite_env_from.rs +0 -0
  57. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/suite_flow_isolation.rs +0 -0
  58. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/suite_missing_trace.rs +0 -0
  59. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/support/sap_simulator.py +0 -0
  60. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/vision_pipeline.rs +0 -0
  61. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-cli/tests/web_e2e.rs +0 -0
  62. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-driver/Cargo.toml +0 -0
  63. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-driver/src/app.rs +0 -0
  64. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-driver/src/backend.rs +0 -0
  65. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-driver/src/gdi.rs +0 -0
  66. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-driver/src/lib.rs +0 -0
  67. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-driver/src/mock.rs +0 -0
  68. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-driver/src/oob.rs +0 -0
  69. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-driver/src/recording.rs +0 -0
  70. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-driver/src/redact.rs +0 -0
  71. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-driver/src/visual.rs +0 -0
  72. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-driver/src/window.rs +0 -0
  73. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-python/Cargo.toml +0 -0
  74. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-python/src/lib.rs +0 -0
  75. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-replay/Cargo.toml +0 -0
  76. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-replay/src/lib.rs +0 -0
  77. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-replay/src/report.rs +0 -0
  78. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-replay/src/runrecord.rs +0 -0
  79. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-replay/tests/replay_calc.rs +0 -0
  80. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-trace/Cargo.toml +0 -0
  81. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-trace/schema/trace-v1.schema.json +0 -0
  82. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-trace/src/cassette_diff.rs +0 -0
  83. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-trace/src/egress.rs +0 -0
  84. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-trace/src/format.rs +0 -0
  85. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-trace/src/lib.rs +0 -0
  86. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-trace/src/secret.rs +0 -0
  87. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-trace/src/secret_scan.rs +0 -0
  88. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-trace/src/substitution.rs +0 -0
  89. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-trace/src/toolcalls.rs +0 -0
  90. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-trace/tests/fixtures/sample.trace.jsonl +0 -0
  91. {flowproof-0.7.0 → flowproof-0.8.0}/crates/flowproof-trace/tests/schema_conformance.rs +0 -0
  92. {flowproof-0.7.0 → flowproof-0.8.0}/flowproof/cli.py +0 -0
  93. {flowproof-0.7.0 → flowproof-0.8.0}/flowproof/flow.py +0 -0
  94. {flowproof-0.7.0 → flowproof-0.8.0}/flowproof/mcp_server.py +0 -0
  95. {flowproof-0.7.0 → flowproof-0.8.0}/flowproof/py.typed +0 -0
@@ -760,7 +760,7 @@ dependencies = [
760
760
 
761
761
  [[package]]
762
762
  name = "flowproof-adapters"
763
- version = "0.7.0"
763
+ version = "0.8.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.7.0"
782
+ version = "0.8.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.7.0"
798
+ version = "0.8.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.7.0"
819
+ version = "0.8.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.7.0"
833
+ version = "0.8.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.7.0"
845
+ version = "0.8.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.7.0"
859
+ version = "0.8.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.7.0"
6
+ version = "0.8.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.7.0
3
+ Version: 0.8.0
4
4
  Classifier: Development Status :: 2 - Pre-Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Programming Language :: Python :: 3
@@ -17,7 +17,7 @@
17
17
 
18
18
  use std::io::{BufRead, BufReader, Read, Write};
19
19
  use std::net::{SocketAddr, TcpListener, TcpStream};
20
- use std::sync::atomic::{AtomicBool, Ordering};
20
+ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
21
21
  use std::sync::{Arc, Mutex};
22
22
 
23
23
  use flowproof_trace::cassette::{
@@ -64,6 +64,12 @@ pub struct AgentProxy {
64
64
  thread: Option<std::thread::JoinHandle<()>>,
65
65
  /// Turns captured in record mode, in order. Empty in replay mode.
66
66
  captured: Arc<Mutex<Vec<Turn>>>,
67
+ /// Bumped once when a request starts and again when it finishes, so a
68
+ /// caller can tell "nothing is happening" from "between two requests".
69
+ /// See [`AgentProxy::quiesce`].
70
+ activity: Arc<AtomicUsize>,
71
+ /// Requests started but not yet finished.
72
+ in_flight: Arc<AtomicUsize>,
67
73
  }
68
74
 
69
75
  impl AgentProxy {
@@ -126,16 +132,41 @@ impl AgentProxy {
126
132
  let log = Arc::new(Mutex::new(ProxyLog::default()));
127
133
  let stop = Arc::new(AtomicBool::new(false));
128
134
  let captured = Arc::new(Mutex::new(Vec::new()));
135
+ let activity = Arc::new(AtomicUsize::new(0));
136
+ let in_flight = Arc::new(AtomicUsize::new(0));
129
137
  let thread = {
130
138
  let (log, stop, captured) =
131
139
  (Arc::clone(&log), Arc::clone(&stop), Arc::clone(&captured));
140
+ let (activity, in_flight) = (Arc::clone(&activity), Arc::clone(&in_flight));
132
141
  std::thread::spawn(move || {
133
142
  let mut turn = 0usize;
143
+ // One flag per recorded turn, marking the ones already
144
+ // served. Matching consumes rather than indexes, so an
145
+ // agent that issues calls concurrently still replays.
146
+ let mut consumed: Vec<bool> = Vec::new();
134
147
  while !stop.load(Ordering::Relaxed) {
135
148
  match listener.accept() {
136
149
  Ok((stream, _)) => {
137
150
  stream.set_nonblocking(false).ok();
138
- serve_one(stream, &mode, &mocks, &mut turn, &log, &captured);
151
+ // Mark the request in flight BEFORE serving it.
152
+ // A record run reads the cassette as soon as the
153
+ // agent exits; without this an agent that fired a
154
+ // call and did not wait for it would have that
155
+ // call forwarded, answered, and then silently
156
+ // missed from the trace.
157
+ in_flight.fetch_add(1, Ordering::SeqCst);
158
+ activity.fetch_add(1, Ordering::SeqCst);
159
+ serve_one(
160
+ stream,
161
+ &mode,
162
+ &mocks,
163
+ &mut turn,
164
+ &mut consumed,
165
+ &log,
166
+ &captured,
167
+ );
168
+ in_flight.fetch_sub(1, Ordering::SeqCst);
169
+ activity.fetch_add(1, Ordering::SeqCst);
139
170
  }
140
171
  Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
141
172
  std::thread::sleep(std::time::Duration::from_millis(5));
@@ -151,9 +182,40 @@ impl AgentProxy {
151
182
  stop,
152
183
  thread: Some(thread),
153
184
  captured,
185
+ activity,
186
+ in_flight,
154
187
  })
155
188
  }
156
189
 
190
+ /// Wait until the proxy has been idle for `grace`, or `cap` elapses.
191
+ ///
192
+ /// An agent process exiting does NOT mean the proxy is done: an agent
193
+ /// can fire a model call and exit without waiting for it (goose does
194
+ /// exactly this, generating a session title). That call is still being
195
+ /// forwarded when the agent's exit status arrives, so reading the
196
+ /// cassette right then races it - and loses, non-deterministically,
197
+ /// whenever the upstream is slow enough. A dropped turn is invisible:
198
+ /// `record` still exits 0 and still writes a trace, which then cannot
199
+ /// replay, because replay serves instantly and always sees the call.
200
+ ///
201
+ /// Idle means both: nothing in flight, AND no request started or
202
+ /// finished during the last `grace` window. The second half matters
203
+ /// because the accept loop is sequential - between finishing one
204
+ /// request and accepting the next that is already queued, `in_flight`
205
+ /// is briefly zero.
206
+ pub fn quiesce(&self, grace: std::time::Duration, cap: std::time::Duration) {
207
+ let deadline = std::time::Instant::now() + cap;
208
+ loop {
209
+ let before = self.activity.load(Ordering::SeqCst);
210
+ std::thread::sleep(grace);
211
+ let settled = self.activity.load(Ordering::SeqCst) == before
212
+ && self.in_flight.load(Ordering::SeqCst) == 0;
213
+ if settled || std::time::Instant::now() >= deadline {
214
+ return;
215
+ }
216
+ }
217
+ }
218
+
157
219
  /// The cassette captured in record mode, in order. Empty in replay.
158
220
  pub fn captured(&self) -> Cassette {
159
221
  Cassette {
@@ -206,6 +268,7 @@ fn serve_one(
206
268
  mode: &Mode,
207
269
  mocks: &Mocks,
208
270
  turn: &mut usize,
271
+ consumed: &mut Vec<bool>,
209
272
  log: &Mutex<ProxyLog>,
210
273
  captured: &Mutex<Vec<Turn>>,
211
274
  ) {
@@ -303,12 +366,11 @@ fn serve_one(
303
366
  }
304
367
  };
305
368
 
306
- let index = *turn;
307
369
  *turn += 1;
308
370
 
309
371
  match mode {
310
- Mode::Replay(cassette) => match cassette.turn(index, &incoming, protocol) {
311
- Ok(recorded) => {
372
+ Mode::Replay(cassette) => match cassette.match_turn(consumed, &incoming, protocol) {
373
+ Ok((_index, recorded)) => {
312
374
  log.lock().unwrap_or_else(|e| e.into_inner()).served += 1;
313
375
  // Render the recorded assistant message in the dialect the
314
376
  // agent asked in. OpenAI honors `stream: true` with a
@@ -599,6 +599,14 @@ fn decide(
599
599
  }
600
600
  }
601
601
 
602
+ /// Temporary diagnostic for D5: why a connect was refused. Off unless
603
+ /// FLOWPROOF_EGRESS_DEBUG is set, so it costs nothing in a normal run.
604
+ fn dbg_egress(what: &str) {
605
+ if std::env::var_os("FLOWPROOF_EGRESS_DEBUG").is_some() {
606
+ eprintln!("[egress] {what}");
607
+ }
608
+ }
609
+
602
610
  /// `connect(fd, addr, addrlen)`: the core TOCTOU-safe path.
603
611
  fn handle_connect(
604
612
  fd: RawFd,
@@ -619,6 +627,7 @@ fn handle_connect(
619
627
  return errno_resp(libc::EPERM);
620
628
  }
621
629
  let Ok(n) = read else {
630
+ dbg_egress("connect: read_child FAILED -> EPERM");
622
631
  return errno_resp(libc::EPERM);
623
632
  };
624
633
 
@@ -628,14 +637,26 @@ fn handle_connect(
628
637
  Some(Dest::Inet(ip, port)) => {
629
638
  let ip = normalize(ip);
630
639
  if allow.allows(ip, port) {
640
+ dbg_egress(&format!("connect: ALLOWED {ip}:{port}, performing"));
631
641
  perform_connect(req.pid, sockfd, &buf[..n])
632
642
  } else {
643
+ dbg_egress(&format!("connect: DENIED by policy {ip}:{port}"));
633
644
  record(log, spawn, &format!("{ip}:{port}"), "tcp");
634
645
  errno_resp(libc::ECONNREFUSED)
635
646
  }
636
647
  }
637
648
  // An address family we do not model: deny rather than guess.
638
- None => errno_resp(libc::EPERM),
649
+ None => {
650
+ dbg_egress(&format!(
651
+ "connect: UNPARSEABLE sockaddr, {n} bytes, family={:?} -> EPERM",
652
+ if n >= 2 {
653
+ u16::from_ne_bytes([buf[0], buf[1]]) as i32
654
+ } else {
655
+ -1
656
+ }
657
+ ));
658
+ errno_resp(libc::EPERM)
659
+ }
639
660
  }
640
661
  }
641
662
 
@@ -778,11 +799,20 @@ fn handle_listen(req: &SeccompNotif) -> SeccompNotifResp {
778
799
  if rc == 0 {
779
800
  ok_resp(0)
780
801
  } else {
781
- errno_resp(
782
- io::Error::last_os_error()
783
- .raw_os_error()
784
- .unwrap_or(libc::EPERM),
785
- )
802
+ let e = io::Error::last_os_error()
803
+ .raw_os_error()
804
+ .unwrap_or(libc::EPERM);
805
+ // EINPROGRESS is the NORMAL result for a nonblocking socket, not a
806
+ // failure: it is handed back to the child, which epolls its own fd.
807
+ let how = if e == libc::EINPROGRESS {
808
+ "in progress"
809
+ } else {
810
+ "FAILED"
811
+ };
812
+ dbg_egress(&format!(
813
+ "perform_connect: supervisor connect {how} errno={e}"
814
+ ));
815
+ errno_resp(e)
786
816
  }
787
817
  }
788
818
 
@@ -792,6 +822,7 @@ fn handle_listen(req: &SeccompNotif) -> SeccompNotifResp {
792
822
  /// correct, and the kernel never re-reads child memory.
793
823
  fn perform_connect(pid: u32, sockfd: RawFd, addr: &[u8]) -> SeccompNotifResp {
794
824
  let Ok(dup) = dup_child_fd(pid, sockfd) else {
825
+ dbg_egress("perform_connect: dup_child_fd FAILED -> EPERM");
795
826
  return errno_resp(libc::EPERM);
796
827
  };
797
828
  let rc = unsafe {
@@ -804,11 +835,20 @@ fn perform_connect(pid: u32, sockfd: RawFd, addr: &[u8]) -> SeccompNotifResp {
804
835
  if rc == 0 {
805
836
  ok_resp(0)
806
837
  } else {
807
- errno_resp(
808
- io::Error::last_os_error()
809
- .raw_os_error()
810
- .unwrap_or(libc::EPERM),
811
- )
838
+ let e = io::Error::last_os_error()
839
+ .raw_os_error()
840
+ .unwrap_or(libc::EPERM);
841
+ // EINPROGRESS is the NORMAL result for a nonblocking socket, not a
842
+ // failure: it is handed back to the child, which epolls its own fd.
843
+ let how = if e == libc::EINPROGRESS {
844
+ "in progress"
845
+ } else {
846
+ "FAILED"
847
+ };
848
+ dbg_egress(&format!(
849
+ "perform_connect: supervisor connect {how} errno={e}"
850
+ ));
851
+ errno_resp(e)
812
852
  }
813
853
  }
814
854
 
@@ -997,12 +1037,50 @@ fn read_child(pid: u32, remote: u64, buf: &mut [u8], want: usize) -> io::Result<
997
1037
  }
998
1038
  }
999
1039
 
1040
+ /// The thread-group leader of `tid`, read from `/proc/<tid>/status`.
1041
+ ///
1042
+ /// `pidfd_open` only accepts a thread-group LEADER, but a seccomp
1043
+ /// notification carries the TID of whichever thread made the syscall. For a
1044
+ /// single-threaded agent those are the same number and nobody notices; for a
1045
+ /// multi-threaded one they are not, and every syscall off a worker thread
1046
+ /// would otherwise fail to open a pidfd.
1047
+ fn tgid_of(tid: u32) -> Option<u32> {
1048
+ let status = std::fs::read_to_string(format!("/proc/{tid}/status")).ok()?;
1049
+ status
1050
+ .lines()
1051
+ .find_map(|line| line.strip_prefix("Tgid:"))
1052
+ .and_then(|v| v.trim().parse().ok())
1053
+ }
1054
+
1000
1055
  /// Dup a fd out of the child into the supervisor (same file description), via
1001
1056
  /// `pidfd_open` + `pidfd_getfd`.
1057
+ ///
1058
+ /// `pid` is the seccomp notification's pid field, which is a TID. When the
1059
+ /// calling thread is not the thread-group leader `pidfd_open` refuses it, so
1060
+ /// fall back to the leader: threads share one file-descriptor table, so the
1061
+ /// fd number means the same thing there and the dup is identical.
1062
+ ///
1063
+ /// This is why a multi-threaded agent used to be denied its own loopback
1064
+ /// connections: goose (Rust/tokio) connects from a worker thread, so every
1065
+ /// `pidfd_open` failed and the supervisor answered EPERM even though the
1066
+ /// destination was allowed. A single-threaded Python or Node agent never hit
1067
+ /// it, which is why flowproof's own examples all passed.
1002
1068
  fn dup_child_fd(pid: u32, fd: RawFd) -> io::Result<OwnedFd> {
1003
- let pidfd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid as libc::pid_t, 0) };
1069
+ let open = |p: u32| unsafe { libc::syscall(libc::SYS_pidfd_open, p as libc::pid_t, 0) };
1070
+ let mut pidfd = open(pid);
1004
1071
  if pidfd < 0 {
1005
- return Err(io::Error::last_os_error());
1072
+ let first = io::Error::last_os_error();
1073
+ match tgid_of(pid) {
1074
+ // Already the leader: the failure is real, report the original.
1075
+ Some(tgid) if tgid == pid => return Err(first),
1076
+ Some(tgid) => {
1077
+ pidfd = open(tgid);
1078
+ if pidfd < 0 {
1079
+ return Err(io::Error::last_os_error());
1080
+ }
1081
+ }
1082
+ None => return Err(first),
1083
+ }
1006
1084
  }
1007
1085
  let pidfd = unsafe { OwnedFd::from_raw_fd(pidfd as RawFd) };
1008
1086
  let newfd = unsafe { libc::syscall(libc::SYS_pidfd_getfd, pidfd.as_raw_fd(), fd, 0) };
@@ -23,8 +23,13 @@
23
23
  //! - `<DIR>/<server>.plan.json` - written by the orchestrator BEFORE the
24
24
  //! agent is spawned: `{mode, command, mocks, calls}` (`calls` is the lane
25
25
  //! to serve, present for replay).
26
- //! - `<DIR>/<server>.out.json` - written by the stand-in ATOMICALLY at stdin
27
- //! EOF: record -> `{calls: [...]}`; replay -> `{served, divergence}`.
26
+ //! - `<DIR>/<server>.out.json` - written by the stand-in ATOMICALLY (temp
27
+ //! file + rename): record -> `{calls: [...]}`; replay ->
28
+ //! `{served, divergence}`. In RECORD it is rewritten after every captured
29
+ //! call, not only at stdin EOF, because reaching EOF needs the agent to
30
+ //! close the stand-in's stdin and an agent that kills its MCP subprocess
31
+ //! never gets there - which used to lose the whole recording. The EOF write
32
+ //! remains the authoritative last one.
28
33
  //!
29
34
  //! No async runtime, matching the model proxy's posture: std threads, plain
30
35
  //! pipes, one blocking read loop per direction.
@@ -220,6 +225,40 @@ fn run_replay(plan: &McpPlan, out_path: &Path) -> Result<(), String> {
220
225
  )
221
226
  }
222
227
 
228
+ /// Persist the captured lane NOW, so an abrupt shutdown cannot lose it.
229
+ ///
230
+ /// The authoritative write is still the one at stdin EOF, but reaching it
231
+ /// requires the agent to close the stand-in's stdin and let it reap the real
232
+ /// server. An agent that terminates its MCP subprocess abruptly never gets
233
+ /// there - goose does exactly this - and the whole recording was lost, which
234
+ /// then read as "the agent never spawned flowproof's MCP stand-in": a false
235
+ /// negative pointing the adopter at wiring that was already correct.
236
+ ///
237
+ /// `write_out_atomic` is a temp-file rename, so a partial flush is never
238
+ /// observable and the last one wins. Errors are swallowed: a failed
239
+ /// incremental flush must not break a run that the EOF write may still save.
240
+ fn flush_lane(
241
+ out_path: &std::path::Path,
242
+ calls: &Mutex<BTreeMap<usize, McpCall>>,
243
+ events: &Mutex<Vec<McpServerEvent>>,
244
+ ) {
245
+ let captured: Vec<McpCall> = calls
246
+ .lock()
247
+ .unwrap_or_else(|e| e.into_inner())
248
+ .values()
249
+ .cloned()
250
+ .collect();
251
+ let events: Vec<McpServerEvent> = events.lock().unwrap_or_else(|e| e.into_inner()).clone();
252
+ let _ = write_out_atomic(
253
+ out_path,
254
+ &McpOut {
255
+ calls: captured,
256
+ events,
257
+ ..Default::default()
258
+ },
259
+ );
260
+ }
261
+
223
262
  /// RECORD: two threads bridge the agent and the REAL server.
224
263
  ///
225
264
  /// Thread A (this thread) reads the agent's stdin: a mocked `tools/call` is
@@ -307,6 +346,7 @@ fn run_record(plan: &McpPlan, out_path: &Path) -> Result<(), String> {
307
346
  let seq = Arc::clone(&seq);
308
347
  let events = Arc::clone(&events);
309
348
  let record_error = Arc::clone(&record_error);
349
+ let out_path_b = out_path.to_path_buf();
310
350
  std::thread::spawn(move || {
311
351
  let mut reader = BufReader::new(server_stdout);
312
352
  let mut line = String::new();
@@ -373,6 +413,7 @@ fn run_record(plan: &McpPlan, out_path: &Path) -> Result<(), String> {
373
413
  result,
374
414
  },
375
415
  );
416
+ flush_lane(&out_path_b, &calls, &events);
376
417
  }
377
418
  }
378
419
  (None, None) => {}
@@ -434,6 +475,7 @@ fn run_record(plan: &McpPlan, out_path: &Path) -> Result<(), String> {
434
475
  result: result.clone(),
435
476
  },
436
477
  );
478
+ flush_lane(out_path, &calls, &events);
437
479
  let mut out = write_lock.lock().unwrap_or_else(|e| e.into_inner());
438
480
  write_result(&mut *out, &id, &result)?;
439
481
  } else {