flowproof 0.4.0__tar.gz → 0.4.1__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 (90) hide show
  1. {flowproof-0.4.0 → flowproof-0.4.1}/Cargo.lock +7 -7
  2. {flowproof-0.4.0 → flowproof-0.4.1}/Cargo.toml +1 -1
  3. {flowproof-0.4.0 → flowproof-0.4.1}/PKG-INFO +1 -1
  4. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-adapters/src/agent_runner.rs +2 -1
  5. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-adapters/src/egress.rs +10 -0
  6. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-adapters/src/egress_linux.rs +178 -88
  7. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-agent/src/lib.rs +1 -1
  8. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-agent/src/recorder.rs +12 -2
  9. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-agent/src/spec.rs +559 -2
  10. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/Cargo.toml +2 -0
  11. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/src/agent_flow.rs +168 -10
  12. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/src/lib.rs +265 -5
  13. flowproof-0.4.1/crates/flowproof-cli/tests/agent_flow_e2e.rs +493 -0
  14. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/tests/egress_e2e.rs +53 -0
  15. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/tests/llm_author_e2e.rs +1 -0
  16. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/tests/notepad_author_e2e.rs +1 -0
  17. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/tests/web_e2e.rs +19 -4
  18. flowproof-0.4.1/crates/flowproof-trace/schema/trace-v1.schema.json +902 -0
  19. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-trace/src/format.rs +101 -0
  20. {flowproof-0.4.0 → flowproof-0.4.1}/flowproof/__init__.py +2 -2
  21. {flowproof-0.4.0 → flowproof-0.4.1}/pyproject.toml +1 -1
  22. flowproof-0.4.0/crates/flowproof-cli/tests/agent_flow_e2e.rs +0 -228
  23. flowproof-0.4.0/crates/flowproof-trace/schema/trace-v1.schema.json +0 -472
  24. {flowproof-0.4.0 → flowproof-0.4.1}/README.md +0 -0
  25. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-adapters/Cargo.toml +0 -0
  26. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-adapters/src/agent_proxy.rs +0 -0
  27. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-adapters/src/lib.rs +0 -0
  28. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-adapters/src/mcp_core.rs +0 -0
  29. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-adapters/src/mcp_http.rs +0 -0
  30. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-adapters/src/mcp_stdio.rs +0 -0
  31. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-adapters/src/sap_com.rs +0 -0
  32. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-adapters/src/vision.rs +0 -0
  33. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-adapters/src/web.rs +0 -0
  34. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-agent/Cargo.toml +0 -0
  35. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-agent/src/agent_steps.rs +0 -0
  36. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-agent/src/author.rs +0 -0
  37. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-agent/src/clarify.rs +0 -0
  38. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-agent/src/heal.rs +0 -0
  39. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-agent/src/llm.rs +0 -0
  40. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-agent/src/rules.rs +0 -0
  41. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/src/capture.rs +0 -0
  42. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/src/main.rs +0 -0
  43. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/tests/api_pipeline.rs +0 -0
  44. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/tests/calc_e2e.rs +0 -0
  45. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/tests/clock_e2e.rs +0 -0
  46. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/tests/examples_resolve.rs +0 -0
  47. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/tests/grid_cell_e2e.rs +0 -0
  48. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/tests/mcp_stdio_e2e.rs +0 -0
  49. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/tests/notepad_e2e.rs +0 -0
  50. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/tests/sap_e2e.rs +0 -0
  51. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/tests/sap_pipeline.rs +0 -0
  52. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/tests/sap_sim_e2e.rs +0 -0
  53. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/tests/scoped_container_e2e.rs +0 -0
  54. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/tests/skip_unless_env.rs +0 -0
  55. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/tests/suite_env_from.rs +0 -0
  56. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/tests/suite_flow_isolation.rs +0 -0
  57. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/tests/suite_missing_trace.rs +0 -0
  58. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/tests/support/sap_simulator.py +0 -0
  59. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-cli/tests/vision_pipeline.rs +0 -0
  60. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-driver/Cargo.toml +0 -0
  61. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-driver/src/app.rs +0 -0
  62. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-driver/src/backend.rs +0 -0
  63. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-driver/src/gdi.rs +0 -0
  64. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-driver/src/lib.rs +0 -0
  65. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-driver/src/mock.rs +0 -0
  66. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-driver/src/oob.rs +0 -0
  67. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-driver/src/recording.rs +0 -0
  68. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-driver/src/redact.rs +0 -0
  69. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-driver/src/visual.rs +0 -0
  70. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-driver/src/window.rs +0 -0
  71. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-python/Cargo.toml +0 -0
  72. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-python/src/lib.rs +0 -0
  73. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-replay/Cargo.toml +0 -0
  74. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-replay/src/lib.rs +0 -0
  75. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-replay/src/report.rs +0 -0
  76. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-replay/tests/replay_calc.rs +0 -0
  77. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-trace/Cargo.toml +0 -0
  78. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-trace/src/cassette.rs +0 -0
  79. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-trace/src/cassette_diff.rs +0 -0
  80. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-trace/src/egress.rs +0 -0
  81. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-trace/src/lib.rs +0 -0
  82. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-trace/src/secret.rs +0 -0
  83. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-trace/src/substitution.rs +0 -0
  84. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-trace/src/toolcalls.rs +0 -0
  85. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-trace/tests/fixtures/sample.trace.jsonl +0 -0
  86. {flowproof-0.4.0 → flowproof-0.4.1}/crates/flowproof-trace/tests/schema_conformance.rs +0 -0
  87. {flowproof-0.4.0 → flowproof-0.4.1}/flowproof/cli.py +0 -0
  88. {flowproof-0.4.0 → flowproof-0.4.1}/flowproof/flow.py +0 -0
  89. {flowproof-0.4.0 → flowproof-0.4.1}/flowproof/mcp_server.py +0 -0
  90. {flowproof-0.4.0 → flowproof-0.4.1}/flowproof/py.typed +0 -0
@@ -760,7 +760,7 @@ dependencies = [
760
760
 
761
761
  [[package]]
762
762
  name = "flowproof-adapters"
763
- version = "0.4.0"
763
+ version = "0.4.1"
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.0"
782
+ version = "0.4.1"
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.0"
798
+ version = "0.4.1"
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.0"
819
+ version = "0.4.1"
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.0"
833
+ version = "0.4.1"
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.0"
845
+ version = "0.4.1"
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.0"
859
+ version = "0.4.1"
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.0"
6
+ version = "0.4.1"
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.0
3
+ Version: 0.4.1
4
4
  Classifier: Development Status :: 2 - Pre-Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Programming Language :: Python :: 3
@@ -391,7 +391,8 @@ pub fn run_against_contained(
391
391
  command: command.trim().to_string(),
392
392
  source,
393
393
  })?;
394
- // Start the supervisor: receive the notify fd and service it for the run.
394
+ // Start the supervisor: collect the notify fd the handoff thread acquired
395
+ // out of the child while `spawn` ran, and service it for the run.
395
396
  let supervisor = prep
396
397
  .into_supervisor(spawned)
397
398
  .map_err(|source| RunError::Spawn {
@@ -46,6 +46,16 @@ impl Containment {
46
46
  }
47
47
  }
48
48
 
49
+ /// The tier for a flow that does not ENGAGE egress: it declares no
50
+ /// `allow_egress` and asserts no egress, so no seccomp filter is installed
51
+ /// and there is nothing to contain. Containment is opt-in; an unengaged
52
+ /// flow claims no tier.
53
+ pub fn not_engaged() -> Self {
54
+ Containment::NotContained(
55
+ "flow does not engage egress (no allow_egress or assert_no_egress)".to_string(),
56
+ )
57
+ }
58
+
49
59
  /// The tier for a `url:` flow: a service flowproof did not start cannot
50
60
  /// be contained.
51
61
  pub fn url_flow() -> Self {
@@ -15,9 +15,22 @@
15
15
  //! closing the two paths that reach the network under the notifier.
16
16
  //!
17
17
  //! The filter is installed with `SECCOMP_FILTER_FLAG_NEW_LISTENER`, whose
18
- //! return value is a notify fd. That fd lives in the child, so it is passed
19
- //! back to the parent over a pre-created `socketpair` using `SCM_RIGHTS`. A
20
- //! supervisor thread services it for the whole run.
18
+ //! return value is a notify fd. That fd lives in the child, so it is handed to
19
+ //! the parent - but NOT with `SCM_RIGHTS`/`sendmsg`, since `sendmsg` is one of
20
+ //! the syscalls the filter traps, so using it here would suspend the child on
21
+ //! the very notifier the parent has not started servicing yet (a deadlock).
22
+ //! Instead the child WRITES its `(pid, notify-fd-number)` over a pre-created
23
+ //! `socketpair` (`getpid`/`write` are not trapped), a PARENT HANDOFF THREAD
24
+ //! acquires the actual fd with `pidfd_open`+`pidfd_getfd` (the same infra the
25
+ //! supervisor uses to act on the child's sockets) and WRITES back a one-byte
26
+ //! ack so the child may close its copy and proceed to exec. A supervisor
27
+ //! thread then services the notify fd for the whole run.
28
+ //!
29
+ //! The handoff runs on its own thread, started BEFORE `Command::spawn`,
30
+ //! because `spawn` blocks until the child execs and the child cannot exec
31
+ //! until it gets the ack: the read+ack must happen concurrently with `spawn`,
32
+ //! not after it. The child sends its own pid because `child.id()` is not yet
33
+ //! available while `spawn` is still blocked.
21
34
  //!
22
35
  //! # The TOCTOU-safe pattern
23
36
  //!
@@ -233,24 +246,32 @@ pub fn probe_containment() -> Containment {
233
246
  Containment::Enforced
234
247
  }
235
248
 
236
- /// A prepared filter and its parent-side control socket, wired into a
237
- /// `Command`'s `pre_exec`. Created BEFORE spawn; turned into a live
238
- /// supervisor AFTER spawn.
249
+ /// A prepared filter plus the in-flight parent-side handoff, wired into a
250
+ /// `Command`'s `pre_exec`. Created BEFORE spawn; turned into a live supervisor
251
+ /// AFTER spawn.
252
+ ///
253
+ /// The handoff runs on its OWN thread, started here in [`install`] rather than
254
+ /// after spawn, and this is load-bearing: `Command::spawn` BLOCKS until the
255
+ /// child execs, and the child cannot exec until the parent acks its notify fd.
256
+ /// If the parent tried to do the read+ack after `spawn` returned, it would
257
+ /// deadlock - the parent waiting in `spawn` for an exec that waits on an ack
258
+ /// the parent has not sent. So the ack must come from a concurrent thread.
239
259
  pub struct EgressPrep {
240
- parent_sock: OwnedFd,
260
+ handoff: std::thread::JoinHandle<io::Result<OwnedFd>>,
241
261
  allow: AllowSet,
242
262
  }
243
263
 
244
264
  /// Install the egress filter into `cmd` via `pre_exec` and return the
245
265
  /// parent-side handle. The filter and the child socket are moved into the
246
- /// closure; the parent keeps its end of the socketpair to receive the notify
247
- /// fd once the child has installed the filter.
266
+ /// `pre_exec` closure; a handoff thread owns the parent's socket end and
267
+ /// acquires the notify fd out of the child while `spawn` runs.
248
268
  pub fn install(cmd: &mut Command, allow: &AllowSet) -> io::Result<EgressPrep> {
249
269
  let filter = build_filter();
250
270
 
251
- // A stream socketpair carries the notify fd from child to parent via
252
- // SCM_RIGHTS. Both ends are close-on-exec: the child sends before exec
253
- // (pre_exec runs first), and the execed program must inherit neither.
271
+ // A stream socketpair carries the child's (pid, notify-fd-number) to the
272
+ // parent and the parent's ack back. Both ends are close-on-exec: the child
273
+ // sends before exec (pre_exec runs first), and the execed program must
274
+ // inherit neither.
254
275
  let mut fds = [0 as RawFd; 2];
255
276
  let rc = unsafe {
256
277
  libc::socketpair(
@@ -273,18 +294,28 @@ pub fn install(cmd: &mut Command, allow: &AllowSet) -> io::Result<EgressPrep> {
273
294
  unsafe {
274
295
  cmd.pre_exec(move || child_install(&filter, child_sock));
275
296
  }
297
+
298
+ // The handoff thread: it blocks reading the child's message, so it must run
299
+ // CONCURRENTLY with the caller's `cmd.spawn()` (see `EgressPrep`). It owns
300
+ // `parent_sock` and yields the acquired notify fd.
301
+ let handoff = std::thread::spawn(move || recv_notify_fd(parent_sock));
302
+
276
303
  Ok(EgressPrep {
277
- parent_sock,
304
+ handoff,
278
305
  allow: allow.clone(),
279
306
  })
280
307
  }
281
308
 
282
309
  impl EgressPrep {
283
- /// After spawn: receive the notify fd the child sent, and start the
284
- /// supervisor thread that services it for the run. `spawn` is the instant
285
- /// the child was launched, the zero for every event's monotonic `at_ms`.
310
+ /// After spawn: collect the notify fd the handoff thread acquired from the
311
+ /// child, and start the supervisor thread that services it for the run.
312
+ /// `spawn` is the instant the child was launched, the zero for every
313
+ /// event's monotonic `at_ms`.
286
314
  pub fn into_supervisor(self, spawn: Instant) -> io::Result<Supervisor> {
287
- let notify_fd = recv_fd(self.parent_sock.as_raw_fd())?;
315
+ let notify_fd = self
316
+ .handoff
317
+ .join()
318
+ .map_err(|_| io::Error::other("egress handoff thread panicked"))??;
288
319
  Ok(Supervisor::start(notify_fd, self.allow, spawn))
289
320
  }
290
321
  }
@@ -302,7 +333,10 @@ fn child_install(filter: &[libc::sock_filter], child_sock: RawFd) -> io::Result<
302
333
  filter: filter.as_ptr() as *mut libc::sock_filter,
303
334
  };
304
335
  // 2. Install the filter with NEW_LISTENER; the return value is the notify
305
- // fd, which the parent will service.
336
+ // fd, which the parent will service. The filter is active IMMEDIATELY,
337
+ // so from here on the trapped syscalls (connect/send*/listen) would
338
+ // suspend us on the notifier - hence the handoff below uses only
339
+ // `write`/`read`, which the filter does NOT trap.
306
340
  let notify_fd = unsafe {
307
341
  libc::syscall(
308
342
  libc::SYS_seccomp,
@@ -314,91 +348,147 @@ fn child_install(filter: &[libc::sock_filter], child_sock: RawFd) -> io::Result<
314
348
  if notify_fd < 0 {
315
349
  return Err(io::Error::last_os_error());
316
350
  }
317
- // 3. Hand the notify fd to the parent over the socketpair.
318
- send_fd(child_sock, notify_fd as RawFd)?;
319
- // The child no longer needs either fd; the filter stays installed.
351
+ let notify_fd = notify_fd as RawFd;
352
+ // 3. Hand the notify fd to the parent WITHOUT a trapped syscall: write our
353
+ // PID and the notify fd's NUMBER, then block on the parent's one-byte
354
+ // ack. The pid lets the handoff thread `pidfd_open`+`pidfd_getfd` the fd
355
+ // (it cannot use `child.id()` - `spawn` has not returned yet, since it
356
+ // blocks until we exec). We must NOT close `notify_fd` until the parent
357
+ // has acquired its own reference to the same open file description;
358
+ // closing early would tear down the listener. `getpid`/`write`/`read`
359
+ // are not in the filter, so none traps and the child never blocks on the
360
+ // un-serviced notifier.
361
+ let pid = unsafe { libc::getpid() };
362
+ let handoff = write_handoff(child_sock, pid, notify_fd).and_then(|()| wait_ack(child_sock));
363
+ // 4. Whether the handoff succeeded or failed, drop both fds before exec so
364
+ // the child's real program (python) inherits NEITHER the notify fd nor
365
+ // the socketpair. The socketpair is already CLOEXEC, but the notify fd
366
+ // is not, so this explicit close is what keeps it out of the exec.
320
367
  unsafe {
321
- libc::close(notify_fd as RawFd);
368
+ libc::close(notify_fd);
322
369
  libc::close(child_sock);
323
370
  }
324
- Ok(())
371
+ handoff
325
372
  }
326
373
 
327
- /// Send a single fd over a unix socket with SCM_RIGHTS (one filler data byte,
328
- /// as the kernel requires a non-empty payload).
329
- fn send_fd(sock: RawFd, fd: RawFd) -> io::Result<()> {
330
- let mut byte = [0u8; 1];
331
- let mut iov = libc::iovec {
332
- iov_base: byte.as_mut_ptr() as *mut libc::c_void,
333
- iov_len: 1,
334
- };
335
- let mut cmsg_buf = [0u8; unsafe_cmsg_space()];
336
- let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
337
- msg.msg_iov = &mut iov;
338
- msg.msg_iovlen = 1;
339
- msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void;
340
- msg.msg_controllen = cmsg_buf.len() as _;
341
- unsafe {
342
- let cmsg = libc::CMSG_FIRSTHDR(&msg);
343
- (*cmsg).cmsg_level = libc::SOL_SOCKET;
344
- (*cmsg).cmsg_type = libc::SCM_RIGHTS;
345
- (*cmsg).cmsg_len = libc::CMSG_LEN(std::mem::size_of::<RawFd>() as u32) as _;
346
- std::ptr::copy_nonoverlapping(
347
- &fd as *const RawFd as *const u8,
348
- libc::CMSG_DATA(cmsg),
349
- std::mem::size_of::<RawFd>(),
350
- );
351
- let n = libc::sendmsg(sock, &msg, 0);
352
- if n < 0 {
353
- return Err(io::Error::last_os_error());
374
+ /// Write the child's `(pid, notify-fd-number)` as two native-endian `i32`s to
375
+ /// the socketpair. Used in place of an `SCM_RIGHTS`/`sendmsg` fd passage,
376
+ /// because `sendmsg` is trapped by the filter that is already installed;
377
+ /// `write` is not.
378
+ fn write_handoff(sock: RawFd, pid: libc::pid_t, fd: RawFd) -> io::Result<()> {
379
+ // RawFd and pid_t are both i32 on Linux: a fixed 8-byte message the parent
380
+ // reads back with `i32::from_ne_bytes`.
381
+ let mut msg = [0u8; 8];
382
+ msg[0..4].copy_from_slice(&pid.to_ne_bytes());
383
+ msg[4..8].copy_from_slice(&fd.to_ne_bytes());
384
+ write_all(sock, &msg)
385
+ }
386
+
387
+ /// Block until the parent writes its one-byte ack, meaning it has acquired the
388
+ /// notify fd and the child may close its copy and proceed to exec. `read` is
389
+ /// not trapped by the filter.
390
+ fn wait_ack(sock: RawFd) -> io::Result<()> {
391
+ let mut ack = [0u8; 1];
392
+ read_exact(sock, &mut ack)
393
+ }
394
+
395
+ /// Parent side of the handoff, run on its own thread (see [`install`]): read
396
+ /// the child's `(pid, notify-fd-number)`, acquire the actual fd out of the
397
+ /// child with `pidfd_open`+`pidfd_getfd` (the same mechanism [`dup_child_fd`]
398
+ /// uses for the child's sockets), then write the one-byte ack so the child can
399
+ /// close its copy and exec. Owns `sock` and drops it on return.
400
+ fn recv_notify_fd(sock: OwnedFd) -> io::Result<OwnedFd> {
401
+ let raw = sock.as_raw_fd();
402
+ // Bound the wait for the child's message. On the happy path the child
403
+ // writes within milliseconds; a bound means that if `cmd.spawn` FAILS (the
404
+ // child never runs) this thread errors out instead of blocking forever on a
405
+ // socketpair whose peer never speaks.
406
+ if !poll_readable(raw, HANDOFF_TIMEOUT_MS)? {
407
+ return Err(io::Error::new(
408
+ io::ErrorKind::TimedOut,
409
+ "egress handoff: child never sent its notify fd",
410
+ ));
411
+ }
412
+ let mut msg = [0u8; 8];
413
+ read_exact(raw, &mut msg)?;
414
+ let child_pid = i32::from_ne_bytes([msg[0], msg[1], msg[2], msg[3]]) as u32;
415
+ let fd_number = i32::from_ne_bytes([msg[4], msg[5], msg[6], msg[7]]) as RawFd;
416
+ // Acquire OUR OWN reference to the child's notify file description. Once we
417
+ // hold it, the child closing its copy does not tear down the listener.
418
+ // pidfd_getfd sets CLOEXEC on the returned fd, so flowproof's own future
419
+ // execs do not leak it.
420
+ let notify_fd = dup_child_fd(child_pid, fd_number)?;
421
+ // Ack: the child is blocked on this one byte before it closes and execs.
422
+ write_all(raw, &[1u8])?;
423
+ Ok(notify_fd)
424
+ }
425
+
426
+ /// How long the parent handoff thread waits for the child's message before
427
+ /// giving up. Generous: the child writes within milliseconds on success, so
428
+ /// this only fires when the child never ran (a failed `cmd.spawn`).
429
+ const HANDOFF_TIMEOUT_MS: libc::c_int = 30_000;
430
+
431
+ /// `poll` a fd for readability with a millisecond timeout. `Ok(true)` means
432
+ /// readable (or an error condition the following read will surface),
433
+ /// `Ok(false)` means the timeout elapsed with nothing to read.
434
+ fn poll_readable(sock: RawFd, timeout_ms: libc::c_int) -> io::Result<bool> {
435
+ loop {
436
+ let mut pfd = libc::pollfd {
437
+ fd: sock,
438
+ events: libc::POLLIN,
439
+ revents: 0,
440
+ };
441
+ let rc = unsafe { libc::poll(&mut pfd, 1, timeout_ms) };
442
+ if rc < 0 {
443
+ let e = io::Error::last_os_error();
444
+ if e.raw_os_error() == Some(libc::EINTR) {
445
+ continue;
446
+ }
447
+ return Err(e);
354
448
  }
449
+ return Ok(rc > 0);
355
450
  }
356
- Ok(())
357
451
  }
358
452
 
359
- /// Receive a single fd sent with SCM_RIGHTS.
360
- fn recv_fd(sock: RawFd) -> io::Result<OwnedFd> {
361
- let mut byte = [0u8; 1];
362
- let mut iov = libc::iovec {
363
- iov_base: byte.as_mut_ptr() as *mut libc::c_void,
364
- iov_len: 1,
365
- };
366
- let mut cmsg_buf = [0u8; unsafe_cmsg_space()];
367
- let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
368
- msg.msg_iov = &mut iov;
369
- msg.msg_iovlen = 1;
370
- msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void;
371
- msg.msg_controllen = cmsg_buf.len() as _;
372
- unsafe {
373
- let n = libc::recvmsg(sock, &mut msg, 0);
453
+ /// `write` the whole buffer, retrying short writes and EINTR. Async-signal-safe
454
+ /// (used in the child): no allocation, so failures carry a bare errno rather
455
+ /// than a formatted message.
456
+ fn write_all(sock: RawFd, mut buf: &[u8]) -> io::Result<()> {
457
+ while !buf.is_empty() {
458
+ let n = unsafe { libc::write(sock, buf.as_ptr() as *const libc::c_void, buf.len()) };
374
459
  if n < 0 {
375
- return Err(io::Error::last_os_error());
460
+ let e = io::Error::last_os_error();
461
+ if e.raw_os_error() == Some(libc::EINTR) {
462
+ continue;
463
+ }
464
+ return Err(e);
376
465
  }
377
- let cmsg = libc::CMSG_FIRSTHDR(&msg);
378
- if cmsg.is_null()
379
- || (*cmsg).cmsg_level != libc::SOL_SOCKET
380
- || (*cmsg).cmsg_type != libc::SCM_RIGHTS
381
- {
382
- return Err(io::Error::other(
383
- "no notify fd in the child's SCM_RIGHTS message",
384
- ));
466
+ if n == 0 {
467
+ return Err(io::Error::from_raw_os_error(libc::EPIPE));
385
468
  }
386
- let mut fd: RawFd = -1;
387
- std::ptr::copy_nonoverlapping(
388
- libc::CMSG_DATA(cmsg),
389
- &mut fd as *mut RawFd as *mut u8,
390
- std::mem::size_of::<RawFd>(),
391
- );
392
- Ok(OwnedFd::from_raw_fd(fd))
469
+ buf = &buf[n as usize..];
393
470
  }
471
+ Ok(())
394
472
  }
395
473
 
396
- /// Room for one fd's control message. `CMSG_SPACE` is not const, so this
397
- /// mirrors its arithmetic for the fixed single-fd case.
398
- const fn unsafe_cmsg_space() -> usize {
399
- // CMSG_SPACE(sizeof(int)) == align(sizeof(cmsghdr)) + align(sizeof(int)).
400
- // 64 bytes is a safe over-allocation on every Linux ABI.
401
- 64
474
+ /// `read` exactly `buf.len()` bytes, retrying short reads and EINTR. A closed
475
+ /// peer (0 bytes) before the buffer is filled is an error. Async-signal-safe.
476
+ fn read_exact(sock: RawFd, mut buf: &mut [u8]) -> io::Result<()> {
477
+ while !buf.is_empty() {
478
+ let n = unsafe { libc::read(sock, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
479
+ if n < 0 {
480
+ let e = io::Error::last_os_error();
481
+ if e.raw_os_error() == Some(libc::EINTR) {
482
+ continue;
483
+ }
484
+ return Err(e);
485
+ }
486
+ if n == 0 {
487
+ return Err(io::Error::from_raw_os_error(libc::ECONNRESET));
488
+ }
489
+ buf = &mut buf[n as usize..];
490
+ }
491
+ Ok(())
402
492
  }
403
493
 
404
494
  /// The live supervisor: a thread servicing the notify fd, plus the shared log
@@ -19,7 +19,7 @@ pub use llm::{HttpModelClient, ModelClient};
19
19
  pub use recorder::{
20
20
  record, record_incremental, record_with_author, Author, RecordError, RecordSummary,
21
21
  };
22
- pub use spec::{FlowSpec, McpServerSpec, SpecStep, SuiteManifest};
22
+ pub use spec::{check_control_ids, FlowSpec, McpServerSpec, SessionRef, SpecStep, SuiteManifest};
23
23
 
24
24
  use std::env;
25
25
 
@@ -1519,7 +1519,10 @@ pub fn record_with_reuse<D: AppDriver, C: ModelClient>(
1519
1519
  ) -> Result<RecordSummary, RecordError> {
1520
1520
  let mut reuse = old_steps.map(ReuseCursor::new);
1521
1521
  let target = launch_target(spec)?;
1522
- if let Some(setup) = &spec.session {
1522
+ // The session is an inline mapping by now: a `session: <name>` ref is
1523
+ // dereferenced to its identity's inline setup at flow load, so record
1524
+ // never sees an unresolved name.
1525
+ if let Some(setup) = spec.session.as_ref().and_then(|s| s.inline()) {
1523
1526
  let (cookies, local_storage) = setup.resolved()?;
1524
1527
  driver.stage_session(flowproof_driver::WebSession {
1525
1528
  cookies,
@@ -2092,7 +2095,10 @@ pub fn record_with_reuse<D: AppDriver, C: ModelClient>(
2092
2095
  .filter_map(|rule| serde_json::to_value(rule).ok())
2093
2096
  .collect(),
2094
2097
  // The RAW session setup — cookie values keep their `${VAR}` refs.
2095
- session: spec.session.clone(),
2098
+ // A `session: <name>` ref has been dereferenced to its identity's
2099
+ // inline setup at flow load, so the header carries the setup itself,
2100
+ // never a pointer to the suite. The trace stays self-contained.
2101
+ session: spec.session.as_ref().and_then(|s| s.inline()).cloned(),
2096
2102
  // Mock rules travel with the trace: what was mocked at record MUST
2097
2103
  // be mocked at replay, or the two executions test different things.
2098
2104
  mock: spec.mock.clone(),
@@ -2167,6 +2173,10 @@ pub fn record_with_reuse<D: AppDriver, C: ModelClient>(
2167
2173
  dpi_scale: None,
2168
2174
  locale: None,
2169
2175
  },
2176
+ // The control this flow validates, copied so the evidence is
2177
+ // self-describing. Additive/optional: absent for flows with no
2178
+ // `control:` block, which serialize byte-identical to before.
2179
+ control: spec.control.clone(),
2170
2180
  };
2171
2181
 
2172
2182
  let io_err = |source: std::io::Error| RecordError::Io {