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.
- {flowproof-0.4.0 → flowproof-0.5.0}/Cargo.lock +7 -7
- {flowproof-0.4.0 → flowproof-0.5.0}/Cargo.toml +1 -1
- {flowproof-0.4.0 → flowproof-0.5.0}/PKG-INFO +1 -1
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/src/agent_runner.rs +2 -1
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/src/egress.rs +10 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/src/egress_linux.rs +178 -88
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-agent/src/lib.rs +1 -1
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-agent/src/recorder.rs +72 -6
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-agent/src/rules.rs +5 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-agent/src/spec.rs +594 -2
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/Cargo.toml +2 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/src/agent_flow.rs +134 -10
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/src/lib.rs +482 -8
- flowproof-0.5.0/crates/flowproof-cli/tests/agent_flow_e2e.rs +508 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/api_pipeline.rs +137 -0
- flowproof-0.5.0/crates/flowproof-cli/tests/audit_record_e2e.rs +218 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/egress_e2e.rs +53 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/llm_author_e2e.rs +1 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/notepad_author_e2e.rs +1 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/web_e2e.rs +191 -4
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-driver/src/oob.rs +14 -9
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-replay/src/lib.rs +118 -8
- flowproof-0.5.0/crates/flowproof-replay/src/runrecord.rs +633 -0
- flowproof-0.5.0/crates/flowproof-trace/schema/trace-v1.schema.json +902 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-trace/src/format.rs +101 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-trace/src/lib.rs +1 -0
- flowproof-0.5.0/crates/flowproof-trace/src/secret_scan.rs +190 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/flowproof/__init__.py +2 -2
- {flowproof-0.4.0 → flowproof-0.5.0}/pyproject.toml +1 -1
- flowproof-0.4.0/crates/flowproof-cli/tests/agent_flow_e2e.rs +0 -228
- flowproof-0.4.0/crates/flowproof-trace/schema/trace-v1.schema.json +0 -472
- {flowproof-0.4.0 → flowproof-0.5.0}/README.md +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/Cargo.toml +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/src/agent_proxy.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/src/lib.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/src/mcp_core.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/src/mcp_http.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/src/mcp_stdio.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/src/sap_com.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/src/vision.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-adapters/src/web.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-agent/Cargo.toml +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-agent/src/agent_steps.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-agent/src/author.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-agent/src/clarify.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-agent/src/heal.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-agent/src/llm.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/src/capture.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/src/main.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/calc_e2e.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/clock_e2e.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/examples_resolve.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/grid_cell_e2e.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/mcp_stdio_e2e.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/notepad_e2e.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/sap_e2e.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/sap_pipeline.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/sap_sim_e2e.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/scoped_container_e2e.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/skip_unless_env.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/suite_env_from.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/suite_flow_isolation.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/suite_missing_trace.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/support/sap_simulator.py +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-cli/tests/vision_pipeline.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-driver/Cargo.toml +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-driver/src/app.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-driver/src/backend.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-driver/src/gdi.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-driver/src/lib.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-driver/src/mock.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-driver/src/recording.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-driver/src/redact.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-driver/src/visual.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-driver/src/window.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-python/Cargo.toml +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-python/src/lib.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-replay/Cargo.toml +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-replay/src/report.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-replay/tests/replay_calc.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-trace/Cargo.toml +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-trace/src/cassette.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-trace/src/cassette_diff.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-trace/src/egress.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-trace/src/secret.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-trace/src/substitution.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-trace/src/toolcalls.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-trace/tests/fixtures/sample.trace.jsonl +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/crates/flowproof-trace/tests/schema_conformance.rs +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/flowproof/cli.py +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/flowproof/flow.py +0 -0
- {flowproof-0.4.0 → flowproof-0.5.0}/flowproof/mcp_server.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
6
|
+
version = "0.5.0"
|
|
7
7
|
edition = "2021"
|
|
8
8
|
license = "Apache-2.0"
|
|
9
9
|
repository = "https://github.com/automators-com/flowproof"
|
|
@@ -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:
|
|
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
|
|
19
|
-
//!
|
|
20
|
-
//!
|
|
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
|
|
237
|
-
/// `Command`'s `pre_exec`. Created BEFORE spawn; turned into a live
|
|
238
|
-
///
|
|
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
|
-
|
|
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;
|
|
247
|
-
/// fd
|
|
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
|
|
252
|
-
//
|
|
253
|
-
// (pre_exec runs first), and the execed program must
|
|
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
|
-
|
|
304
|
+
handoff,
|
|
278
305
|
allow: allow.clone(),
|
|
279
306
|
})
|
|
280
307
|
}
|
|
281
308
|
|
|
282
309
|
impl EgressPrep {
|
|
283
|
-
/// After spawn:
|
|
284
|
-
/// supervisor thread that services it for the run.
|
|
285
|
-
/// the child was launched, the zero for every
|
|
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 =
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
//
|
|
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
|
|
368
|
+
libc::close(notify_fd);
|
|
322
369
|
libc::close(child_sock);
|
|
323
370
|
}
|
|
324
|
-
|
|
371
|
+
handoff
|
|
325
372
|
}
|
|
326
373
|
|
|
327
|
-
///
|
|
328
|
-
///
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
///
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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
|
-
///
|
|
397
|
-
///
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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<
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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(['/', '\\'])
|