flowproof 0.4.0__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 (93) hide show
  1. {flowproof-0.4.0 → flowproof-0.5.0}/Cargo.lock +7 -7
  2. {flowproof-0.4.0 → flowproof-0.5.0}/Cargo.toml +1 -1
  3. {flowproof-0.4.0 → flowproof-0.5.0}/PKG-INFO +1 -1
  4. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/src/agent_runner.rs +2 -1
  5. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/src/egress.rs +10 -0
  6. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/src/egress_linux.rs +178 -88
  7. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-agent/src/lib.rs +1 -1
  8. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-agent/src/recorder.rs +72 -6
  9. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-agent/src/rules.rs +5 -0
  10. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-agent/src/spec.rs +594 -2
  11. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/Cargo.toml +2 -0
  12. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/src/agent_flow.rs +134 -10
  13. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/src/lib.rs +482 -8
  14. flowproof-0.5.0/crates/flowproof-cli/tests/agent_flow_e2e.rs +508 -0
  15. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/api_pipeline.rs +137 -0
  16. flowproof-0.5.0/crates/flowproof-cli/tests/audit_record_e2e.rs +218 -0
  17. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/egress_e2e.rs +53 -0
  18. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/llm_author_e2e.rs +1 -0
  19. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/notepad_author_e2e.rs +1 -0
  20. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/web_e2e.rs +191 -4
  21. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-driver/src/oob.rs +14 -9
  22. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-replay/src/lib.rs +118 -8
  23. flowproof-0.5.0/crates/flowproof-replay/src/runrecord.rs +633 -0
  24. flowproof-0.5.0/crates/flowproof-trace/schema/trace-v1.schema.json +902 -0
  25. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-trace/src/format.rs +101 -0
  26. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-trace/src/lib.rs +1 -0
  27. flowproof-0.5.0/crates/flowproof-trace/src/secret_scan.rs +190 -0
  28. {flowproof-0.4.0 → flowproof-0.5.0}/flowproof/__init__.py +2 -2
  29. {flowproof-0.4.0 → flowproof-0.5.0}/pyproject.toml +1 -1
  30. flowproof-0.4.0/crates/flowproof-cli/tests/agent_flow_e2e.rs +0 -228
  31. flowproof-0.4.0/crates/flowproof-trace/schema/trace-v1.schema.json +0 -472
  32. {flowproof-0.4.0 → flowproof-0.5.0}/README.md +0 -0
  33. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/Cargo.toml +0 -0
  34. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/src/agent_proxy.rs +0 -0
  35. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/src/lib.rs +0 -0
  36. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/src/mcp_core.rs +0 -0
  37. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/src/mcp_http.rs +0 -0
  38. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/src/mcp_stdio.rs +0 -0
  39. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/src/sap_com.rs +0 -0
  40. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/src/vision.rs +0 -0
  41. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/src/web.rs +0 -0
  42. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-agent/Cargo.toml +0 -0
  43. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-agent/src/agent_steps.rs +0 -0
  44. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-agent/src/author.rs +0 -0
  45. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-agent/src/clarify.rs +0 -0
  46. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-agent/src/heal.rs +0 -0
  47. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-agent/src/llm.rs +0 -0
  48. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/src/capture.rs +0 -0
  49. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/src/main.rs +0 -0
  50. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/calc_e2e.rs +0 -0
  51. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/clock_e2e.rs +0 -0
  52. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/examples_resolve.rs +0 -0
  53. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/grid_cell_e2e.rs +0 -0
  54. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/mcp_stdio_e2e.rs +0 -0
  55. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/notepad_e2e.rs +0 -0
  56. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/sap_e2e.rs +0 -0
  57. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/sap_pipeline.rs +0 -0
  58. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/sap_sim_e2e.rs +0 -0
  59. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/scoped_container_e2e.rs +0 -0
  60. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/skip_unless_env.rs +0 -0
  61. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/suite_env_from.rs +0 -0
  62. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/suite_flow_isolation.rs +0 -0
  63. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/suite_missing_trace.rs +0 -0
  64. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/support/sap_simulator.py +0 -0
  65. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/vision_pipeline.rs +0 -0
  66. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-driver/Cargo.toml +0 -0
  67. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-driver/src/app.rs +0 -0
  68. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-driver/src/backend.rs +0 -0
  69. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-driver/src/gdi.rs +0 -0
  70. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-driver/src/lib.rs +0 -0
  71. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-driver/src/mock.rs +0 -0
  72. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-driver/src/recording.rs +0 -0
  73. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-driver/src/redact.rs +0 -0
  74. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-driver/src/visual.rs +0 -0
  75. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-driver/src/window.rs +0 -0
  76. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-python/Cargo.toml +0 -0
  77. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-python/src/lib.rs +0 -0
  78. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-replay/Cargo.toml +0 -0
  79. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-replay/src/report.rs +0 -0
  80. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-replay/tests/replay_calc.rs +0 -0
  81. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-trace/Cargo.toml +0 -0
  82. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-trace/src/cassette.rs +0 -0
  83. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-trace/src/cassette_diff.rs +0 -0
  84. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-trace/src/egress.rs +0 -0
  85. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-trace/src/secret.rs +0 -0
  86. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-trace/src/substitution.rs +0 -0
  87. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-trace/src/toolcalls.rs +0 -0
  88. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-trace/tests/fixtures/sample.trace.jsonl +0 -0
  89. {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-trace/tests/schema_conformance.rs +0 -0
  90. {flowproof-0.4.0 → flowproof-0.5.0}/flowproof/cli.py +0 -0
  91. {flowproof-0.4.0 → flowproof-0.5.0}/flowproof/flow.py +0 -0
  92. {flowproof-0.4.0 → flowproof-0.5.0}/flowproof/mcp_server.py +0 -0
  93. {flowproof-0.4.0 → 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.0"
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.0"
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.0"
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.0"
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.0"
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.0"
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.0"
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.0"
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.0
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
@@ -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
 
@@ -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,8 +1526,27 @@ 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
- if let Some(setup) = &spec.session {
1546
+ // The session is an inline mapping by now: a `session: <name>` ref is
1547
+ // dereferenced to its identity's inline setup at flow load, so record
1548
+ // never sees an unresolved name.
1549
+ if let Some(setup) = spec.session.as_ref().and_then(|s| s.inline()) {
1523
1550
  let (cookies, local_storage) = setup.resolved()?;
1524
1551
  driver.stage_session(flowproof_driver::WebSession {
1525
1552
  cookies,
@@ -1578,6 +1605,12 @@ pub fn record_with_reuse<D: AppDriver, C: ModelClient>(
1578
1605
  let mut captures: std::collections::HashMap<String, String> = std::collections::HashMap::new();
1579
1606
  let mut prior_intents: Vec<String> = Vec::new();
1580
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();
1581
1614
  for spec_step in &spec.steps {
1582
1615
  let intent = spec_step.intent().to_string();
1583
1616
  let actions = author_actions(
@@ -1711,7 +1744,7 @@ pub fn record_with_reuse<D: AppDriver, C: ModelClient>(
1711
1744
  None => None,
1712
1745
  },
1713
1746
  };
1714
- poll_oob(&probe, *timeout_ms, &spec_step.intent())?
1747
+ poll_oob(&probe, *timeout_ms, &spec_step.intent())?;
1715
1748
  }
1716
1749
  ResolvedAction::AssertApi {
1717
1750
  method,
@@ -1744,7 +1777,14 @@ pub fn record_with_reuse<D: AppDriver, C: ModelClient>(
1744
1777
  None => None,
1745
1778
  },
1746
1779
  };
1747
- 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
+ }
1748
1788
  }
1749
1789
  ResolvedAction::PressKey { key, modifiers } => {
1750
1790
  let mods: Vec<flowproof_driver::KeyMod> =
@@ -2060,6 +2100,25 @@ pub fn record_with_reuse<D: AppDriver, C: ModelClient>(
2060
2100
  }
2061
2101
  }
2062
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)?;
2063
2122
  }
2064
2123
 
2065
2124
  let recording = recorder.and_then(flowproof_driver::RunRecorder::finish);
@@ -2092,7 +2151,10 @@ pub fn record_with_reuse<D: AppDriver, C: ModelClient>(
2092
2151
  .filter_map(|rule| serde_json::to_value(rule).ok())
2093
2152
  .collect(),
2094
2153
  // The RAW session setup — cookie values keep their `${VAR}` refs.
2095
- session: spec.session.clone(),
2154
+ // A `session: <name>` ref has been dereferenced to its identity's
2155
+ // inline setup at flow load, so the header carries the setup itself,
2156
+ // never a pointer to the suite. The trace stays self-contained.
2157
+ session: spec.session.as_ref().and_then(|s| s.inline()).cloned(),
2096
2158
  // Mock rules travel with the trace: what was mocked at record MUST
2097
2159
  // be mocked at replay, or the two executions test different things.
2098
2160
  mock: spec.mock.clone(),
@@ -2167,6 +2229,10 @@ pub fn record_with_reuse<D: AppDriver, C: ModelClient>(
2167
2229
  dpi_scale: None,
2168
2230
  locale: None,
2169
2231
  },
2232
+ // The control this flow validates, copied so the evidence is
2233
+ // self-describing. Additive/optional: absent for flows with no
2234
+ // `control:` block, which serialize byte-identical to before.
2235
+ control: spec.control.clone(),
2170
2236
  };
2171
2237
 
2172
2238
  let io_err = |source: std::io::Error| RecordError::Io {
@@ -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(['/', '\\'])