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