flowproof 0.9.0__tar.gz → 0.9.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {flowproof-0.9.0 → flowproof-0.9.1}/Cargo.lock +7 -7
- {flowproof-0.9.0 → flowproof-0.9.1}/Cargo.toml +1 -1
- {flowproof-0.9.0 → flowproof-0.9.1}/PKG-INFO +2 -2
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/src/agent_runner.rs +117 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/src/sap_com.rs +52 -7
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-agent/src/recorder.rs +14 -1
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/src/agent_flow.rs +58 -1
- flowproof-0.9.1/crates/flowproof-cli/tests/falsifiability_audit_since.rs +135 -0
- flowproof-0.9.1/crates/flowproof-cli/tests/falsifiability_control_verdict.rs +189 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/sap_e2e.rs +18 -11
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/support/sap_simulator.py +33 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/flowproof/__init__.py +1 -1
- {flowproof-0.9.0 → flowproof-0.9.1}/pyproject.toml +2 -2
- {flowproof-0.9.0 → flowproof-0.9.1}/README.md +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/Cargo.toml +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/src/agent_proxy.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/src/egress.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/src/egress_linux.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/src/lib.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/src/mcp_core.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/src/mcp_http.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/src/mcp_stdio.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/src/vision.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/src/web.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-agent/Cargo.toml +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-agent/src/agent_steps.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-agent/src/author.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-agent/src/clarify.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-agent/src/heal.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-agent/src/lib.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-agent/src/llm.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-agent/src/rules.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-agent/src/spec.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/Cargo.toml +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/src/capture.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/src/lib.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/src/main.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/agent_flow_e2e.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/api_pipeline.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/audit_record_e2e.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/calc_e2e.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/clock_e2e.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/cookie_e2e.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/doctor_e2e.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/egress_e2e.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/examples_resolve.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/grid_cell_e2e.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/iframe_e2e.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/llm_author_e2e.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/mcp_stdio_e2e.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/notepad_author_e2e.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/notepad_e2e.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/page_title_e2e.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/sap_pipeline.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/sap_sim_e2e.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/scoped_container_e2e.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/skip_unless_env.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/suite_env_from.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/suite_flow_isolation.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/suite_missing_trace.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/vision_pipeline.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/web_e2e.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-driver/Cargo.toml +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-driver/src/app.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-driver/src/backend.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-driver/src/gdi.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-driver/src/lib.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-driver/src/mock.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-driver/src/oob.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-driver/src/recording.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-driver/src/redact.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-driver/src/visual.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-driver/src/window.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-python/Cargo.toml +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-python/src/lib.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-replay/Cargo.toml +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-replay/src/lib.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-replay/src/report.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-replay/src/runrecord.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-replay/tests/replay_calc.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/Cargo.toml +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/schema/trace-v1.schema.json +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/src/cassette.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/src/cassette_diff.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/src/egress.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/src/format.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/src/lib.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/src/secret.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/src/secret_scan.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/src/substitution.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/src/toolcalls.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/tests/fixtures/sample.trace.jsonl +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/tests/schema_conformance.rs +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/flowproof/cli.py +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/flowproof/flow.py +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/flowproof/mcp_server.py +0 -0
- {flowproof-0.9.0 → flowproof-0.9.1}/flowproof/py.typed +0 -0
|
@@ -760,7 +760,7 @@ dependencies = [
|
|
|
760
760
|
|
|
761
761
|
[[package]]
|
|
762
762
|
name = "flowproof-adapters"
|
|
763
|
-
version = "0.9.
|
|
763
|
+
version = "0.9.1"
|
|
764
764
|
dependencies = [
|
|
765
765
|
"anyhow",
|
|
766
766
|
"flowproof-driver",
|
|
@@ -779,7 +779,7 @@ dependencies = [
|
|
|
779
779
|
|
|
780
780
|
[[package]]
|
|
781
781
|
name = "flowproof-agent"
|
|
782
|
-
version = "0.9.
|
|
782
|
+
version = "0.9.1"
|
|
783
783
|
dependencies = [
|
|
784
784
|
"chrono",
|
|
785
785
|
"flowproof-driver",
|
|
@@ -795,7 +795,7 @@ dependencies = [
|
|
|
795
795
|
|
|
796
796
|
[[package]]
|
|
797
797
|
name = "flowproof-cli"
|
|
798
|
-
version = "0.9.
|
|
798
|
+
version = "0.9.1"
|
|
799
799
|
dependencies = [
|
|
800
800
|
"ab_glyph",
|
|
801
801
|
"chrono",
|
|
@@ -816,7 +816,7 @@ dependencies = [
|
|
|
816
816
|
|
|
817
817
|
[[package]]
|
|
818
818
|
name = "flowproof-driver"
|
|
819
|
-
version = "0.9.
|
|
819
|
+
version = "0.9.1"
|
|
820
820
|
dependencies = [
|
|
821
821
|
"image",
|
|
822
822
|
"postgres",
|
|
@@ -830,7 +830,7 @@ dependencies = [
|
|
|
830
830
|
|
|
831
831
|
[[package]]
|
|
832
832
|
name = "flowproof-python"
|
|
833
|
-
version = "0.9.
|
|
833
|
+
version = "0.9.1"
|
|
834
834
|
dependencies = [
|
|
835
835
|
"flowproof-agent",
|
|
836
836
|
"flowproof-cli",
|
|
@@ -842,7 +842,7 @@ dependencies = [
|
|
|
842
842
|
|
|
843
843
|
[[package]]
|
|
844
844
|
name = "flowproof-replay"
|
|
845
|
-
version = "0.9.
|
|
845
|
+
version = "0.9.1"
|
|
846
846
|
dependencies = [
|
|
847
847
|
"chrono",
|
|
848
848
|
"flowproof-agent",
|
|
@@ -856,7 +856,7 @@ dependencies = [
|
|
|
856
856
|
|
|
857
857
|
[[package]]
|
|
858
858
|
name = "flowproof-trace"
|
|
859
|
-
version = "0.9.
|
|
859
|
+
version = "0.9.1"
|
|
860
860
|
dependencies = [
|
|
861
861
|
"jsonschema",
|
|
862
862
|
"regex",
|
|
@@ -3,7 +3,7 @@ resolver = "2"
|
|
|
3
3
|
members = ["crates/flowproof-driver", "crates/flowproof-trace", "crates/flowproof-replay", "crates/flowproof-agent", "crates/flowproof-adapters", "crates/flowproof-cli", "crates/flowproof-python"]
|
|
4
4
|
|
|
5
5
|
[workspace.package]
|
|
6
|
-
version = "0.9.
|
|
6
|
+
version = "0.9.1"
|
|
7
7
|
edition = "2021"
|
|
8
8
|
license = "Apache-2.0"
|
|
9
9
|
repository = "https://github.com/automators-com/flowproof"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flowproof
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.1
|
|
4
4
|
Classifier: Development Status :: 2 - Pre-Alpha
|
|
5
5
|
Classifier: Intended Audience :: Developers
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -8,7 +8,7 @@ Classifier: Programming Language :: Rust
|
|
|
8
8
|
Classifier: Topic :: Software Development :: Testing
|
|
9
9
|
Requires-Dist: mcp>=1.2 ; python_full_version >= '3.10' and extra == 'mcp'
|
|
10
10
|
Provides-Extra: mcp
|
|
11
|
-
Summary: Deterministic
|
|
11
|
+
Summary: Deterministic evidence and control-regression gating for AI agents and the SAP, web and Citrix systems they drive. Record once, replay with zero LLM calls.
|
|
12
12
|
Author-email: Automators <hello@automators.com>
|
|
13
13
|
License-Expression: Apache-2.0
|
|
14
14
|
Requires-Python: >=3.9
|
|
@@ -36,6 +36,31 @@ use crate::egress::{AllowSet, EgressLog};
|
|
|
36
36
|
/// upstream.
|
|
37
37
|
const BASE_URL_VARS: [&str; 3] = ["OPENAI_BASE_URL", "OPENAI_API_BASE", "OPENAI_BASE"];
|
|
38
38
|
|
|
39
|
+
/// Every variable that can carry a REAL model credential, and which the agent
|
|
40
|
+
/// must therefore never see.
|
|
41
|
+
///
|
|
42
|
+
/// The agent is third-party code. flowproof holds the upstream key and attaches
|
|
43
|
+
/// it only on the outbound hop, so the agent is handed a placeholder — that was
|
|
44
|
+
/// always the design for `OPENAI_API_KEY` and `ANTHROPIC_API_KEY`, which are
|
|
45
|
+
/// overwritten below.
|
|
46
|
+
///
|
|
47
|
+
/// What was missing is that overwriting two names is not the same as masking
|
|
48
|
+
/// the credential. `Command` inherits the parent environment, and the *first*
|
|
49
|
+
/// name `flowproof record` looks in is `FLOWPROOF_AGENT_KEY` — so the
|
|
50
|
+
/// recommended way to supply the key was also the one way it reached the agent
|
|
51
|
+
/// verbatim. `FLOWPROOF_AI_API_KEY`, which the LLM-authoring backend reads, did
|
|
52
|
+
/// the same.
|
|
53
|
+
///
|
|
54
|
+
/// Removed rather than placeheld: a variable flowproof does not hand out has no
|
|
55
|
+
/// business having a value, and an agent reading one is asking a question it
|
|
56
|
+
/// should not get an answer to.
|
|
57
|
+
pub const CREDENTIAL_VARS: [&str; 4] = [
|
|
58
|
+
"FLOWPROOF_AGENT_KEY",
|
|
59
|
+
"FLOWPROOF_AI_API_KEY",
|
|
60
|
+
"ANTHROPIC_API_KEY",
|
|
61
|
+
"OPENAI_API_KEY",
|
|
62
|
+
];
|
|
63
|
+
|
|
39
64
|
/// Split a command line into argv, honouring double quotes so a path
|
|
40
65
|
/// with spaces survives.
|
|
41
66
|
///
|
|
@@ -366,6 +391,13 @@ fn configure(
|
|
|
366
391
|
for var in BASE_URL_VARS {
|
|
367
392
|
child.env(var, base);
|
|
368
393
|
}
|
|
394
|
+
// Drop every credential name BEFORE handing out placeholders. `Command`
|
|
395
|
+
// inherits this process's environment, so a name flowproof does not
|
|
396
|
+
// explicitly set is passed through — and the record path runs with a real
|
|
397
|
+
// key in exactly these variables.
|
|
398
|
+
for var in CREDENTIAL_VARS {
|
|
399
|
+
child.env_remove(var);
|
|
400
|
+
}
|
|
369
401
|
child.env("OPENAI_API_KEY", "flowproof-replay-no-key-needed");
|
|
370
402
|
// The Anthropic SDK reads its own base URL and appends `/v1/messages`
|
|
371
403
|
// itself, so it wants the origin WITHOUT the `/v1` the OpenAI vars keep;
|
|
@@ -707,6 +739,91 @@ mod tests {
|
|
|
707
739
|
assert_eq!(out.len(), 200_000, "every byte the child wrote is captured");
|
|
708
740
|
}
|
|
709
741
|
|
|
742
|
+
/// What `configure` decided for one variable: `Some` to set it, `None`
|
|
743
|
+
/// if it is explicitly removed, and `None` from the outer option if it
|
|
744
|
+
/// was never mentioned — which, since `Command` inherits, means the
|
|
745
|
+
/// child gets whatever this process has.
|
|
746
|
+
fn decided(cmd: &Command, key: &str) -> Option<Option<String>> {
|
|
747
|
+
cmd.get_envs()
|
|
748
|
+
.find(|(k, _)| *k == std::ffi::OsStr::new(key))
|
|
749
|
+
.map(|(_, v)| v.map(|v| v.to_string_lossy().into_owned()))
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/// The agent is third-party code and flowproof holds the real key, so the
|
|
753
|
+
/// key must not be in the agent's environment. Two names were: the record
|
|
754
|
+
/// path reads `FLOWPROOF_AGENT_KEY` FIRST, so the recommended way to
|
|
755
|
+
/// supply the credential was also the one way it reached the agent intact.
|
|
756
|
+
///
|
|
757
|
+
/// Asserted on the `Command` rather than by spawning. Proving it by
|
|
758
|
+
/// spawning means putting a real value in this process's environment, and
|
|
759
|
+
/// `set_var` is process-global — with tests running in parallel that is a
|
|
760
|
+
/// race, which is exactly what #236 was. `env_remove` guarantees the child
|
|
761
|
+
/// sees nothing regardless of the parent, so reading the decision is both
|
|
762
|
+
/// deterministic and a stronger claim than one spawn happening to be clean.
|
|
763
|
+
#[test]
|
|
764
|
+
fn a_real_credential_is_never_handed_to_the_agent() {
|
|
765
|
+
let cmd =
|
|
766
|
+
configure("echo hi", "http://127.0.0.1:9/v1", &BTreeMap::new()).expect("configure");
|
|
767
|
+
for var in ["FLOWPROOF_AGENT_KEY", "FLOWPROOF_AI_API_KEY"] {
|
|
768
|
+
assert_eq!(
|
|
769
|
+
decided(&cmd, var),
|
|
770
|
+
Some(None),
|
|
771
|
+
"{var} must be REMOVED from the agent's environment; \
|
|
772
|
+
inheriting it hands third-party code the real upstream key"
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/// The other direction, and it is not implied by the one above: a client
|
|
778
|
+
/// that refuses to start without a key must still get one, or masking the
|
|
779
|
+
/// credential would break every agent it protects. Placeholders, not
|
|
780
|
+
/// removal, for exactly these two.
|
|
781
|
+
#[test]
|
|
782
|
+
fn the_agent_still_gets_a_key_shaped_placeholder() {
|
|
783
|
+
let cmd =
|
|
784
|
+
configure("echo hi", "http://127.0.0.1:9/v1", &BTreeMap::new()).expect("configure");
|
|
785
|
+
for var in ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] {
|
|
786
|
+
assert_eq!(
|
|
787
|
+
decided(&cmd, var),
|
|
788
|
+
Some(Some("flowproof-replay-no-key-needed".to_string())),
|
|
789
|
+
"{var} must be a placeholder: present, and worthless"
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/// Every name that can hold a real credential has to be covered, not just
|
|
795
|
+
/// the two that were noticed. This fails when someone adds a fifth.
|
|
796
|
+
#[test]
|
|
797
|
+
fn every_credential_variable_is_accounted_for() {
|
|
798
|
+
let cmd =
|
|
799
|
+
configure("echo hi", "http://127.0.0.1:9/v1", &BTreeMap::new()).expect("configure");
|
|
800
|
+
for var in CREDENTIAL_VARS {
|
|
801
|
+
let d = decided(&cmd, var);
|
|
802
|
+
assert!(
|
|
803
|
+
d == Some(None) || d == Some(Some("flowproof-replay-no-key-needed".to_string())),
|
|
804
|
+
"{var} is in CREDENTIAL_VARS but configure() neither removes it \
|
|
805
|
+
nor replaces it with the placeholder; it would be inherited"
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/// A spec that deliberately passes a credential through — `agent.env` with
|
|
811
|
+
/// `ANTHROPIC_API_KEY: ${SECRET}` — is a documented escape hatch, and spec
|
|
812
|
+
/// env is applied last precisely so it wins. Masking must not quietly take
|
|
813
|
+
/// that away; an adopter relying on it would see their agent lose its key
|
|
814
|
+
/// with no error.
|
|
815
|
+
#[test]
|
|
816
|
+
fn a_spec_can_still_pass_a_key_through_on_purpose() {
|
|
817
|
+
let mut env = BTreeMap::new();
|
|
818
|
+
env.insert("ANTHROPIC_API_KEY".to_string(), "sk-deliberate".to_string());
|
|
819
|
+
let cmd = configure("echo hi", "http://127.0.0.1:9/v1", &env).expect("configure");
|
|
820
|
+
assert_eq!(
|
|
821
|
+
decided(&cmd, "ANTHROPIC_API_KEY"),
|
|
822
|
+
Some(Some("sk-deliberate".to_string())),
|
|
823
|
+
"the spec's own env is applied last and must still override"
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
|
|
710
827
|
/// The gap a real adopter hit: their client reads AI_GATEWAY_URL, and
|
|
711
828
|
/// the proxy's port is not known when the spec is written, so a static
|
|
712
829
|
/// `agent.env` value could not reach it.
|
|
@@ -605,6 +605,39 @@ pub mod com {
|
|
|
605
605
|
.map(Disp)
|
|
606
606
|
}
|
|
607
607
|
|
|
608
|
+
/// Log in on SAP's own logon screen, using the standard fields every
|
|
609
|
+
/// SAP system ships (client/user/password - the same ones a VBScript
|
|
610
|
+
/// recording of a manual login would drive). A no-op unless both
|
|
611
|
+
/// SAP_USER and SAP_PASSWORD are set: attach-only and an explicit
|
|
612
|
+
/// OpenConnection-without-login stay exactly as they were before this
|
|
613
|
+
/// existed. Values are read fresh from the environment and used only
|
|
614
|
+
/// as this one property-put each - never logged, never traced, never
|
|
615
|
+
/// stored anywhere by this function.
|
|
616
|
+
fn try_auto_login(session: &Disp) {
|
|
617
|
+
let (Ok(user), Ok(password)) =
|
|
618
|
+
(std::env::var("SAP_USER"), std::env::var("SAP_PASSWORD"))
|
|
619
|
+
else {
|
|
620
|
+
return;
|
|
621
|
+
};
|
|
622
|
+
eprintln!("sap-com: not logged in - attempting auto-login");
|
|
623
|
+
if let Some(client) = std::env::var("SAP_CLIENT").ok().filter(|c| !c.is_empty()) {
|
|
624
|
+
if let Some(field) = Self::find_in(session, "wnd[0]/usr/txtRSYST-MANDT") {
|
|
625
|
+
let _ = field.put("Text", VARIANT::from(BSTR::from(client.as_str())));
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if let Some(field) = Self::find_in(session, "wnd[0]/usr/txtRSYST-BNAME") {
|
|
629
|
+
let _ = field.put("Text", VARIANT::from(BSTR::from(user.as_str())));
|
|
630
|
+
}
|
|
631
|
+
if let Some(field) = Self::find_in(session, "wnd[0]/usr/pwdRSYST-BCODE") {
|
|
632
|
+
let _ = field.put("Text", VARIANT::from(BSTR::from(password.as_str())));
|
|
633
|
+
}
|
|
634
|
+
// Submit - Enter on the window, exactly how navigate() confirms
|
|
635
|
+
// any other screen.
|
|
636
|
+
if let Some(window) = Self::find_in(session, "wnd[0]") {
|
|
637
|
+
let _ = window.call("SendVKey", vec![VARIANT::from(0i32)]);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
608
641
|
fn walk_into(element: &Disp, depth: u32, out: &mut Vec<SapElement>) {
|
|
609
642
|
if depth > 14 || out.len() >= 400 {
|
|
610
643
|
return;
|
|
@@ -698,8 +731,9 @@ pub mod com {
|
|
|
698
731
|
}
|
|
699
732
|
let deadline = Instant::now() + timeout;
|
|
700
733
|
let mut opened = false;
|
|
734
|
+
let mut login_attempted = false;
|
|
701
735
|
loop {
|
|
702
|
-
let attempt = (|| -> Result<Option<Disp>, DriverError> {
|
|
736
|
+
let attempt = (|| -> Result<(Option<Disp>, bool), DriverError> {
|
|
703
737
|
let sapgui = Disp(attach_to_sapgui()?);
|
|
704
738
|
let engine = sapgui.call_disp("GetScriptingEngine", vec![])?;
|
|
705
739
|
let connections = engine.get_disp("Children")?;
|
|
@@ -710,12 +744,12 @@ pub mod com {
|
|
|
710
744
|
vec![VARIANT::from(BSTR::from(connection)), VARIANT::from(true)],
|
|
711
745
|
)?;
|
|
712
746
|
}
|
|
713
|
-
return Ok(None); // keep waiting for a session
|
|
747
|
+
return Ok((None, false)); // keep waiting for a session
|
|
714
748
|
}
|
|
715
749
|
let conn = connections.call_disp("ElementAt", vec![VARIANT::from(0)])?;
|
|
716
750
|
let sessions = conn.get_disp("Children")?;
|
|
717
751
|
if sessions.get_i32("Count").unwrap_or(0) == 0 {
|
|
718
|
-
return Ok(None);
|
|
752
|
+
return Ok((None, false));
|
|
719
753
|
}
|
|
720
754
|
let session = sessions.call_disp("ElementAt", vec![VARIANT::from(0)])?;
|
|
721
755
|
// A session object exists as soon as its window opens -
|
|
@@ -732,17 +766,28 @@ pub mod com {
|
|
|
732
766
|
.map(|info| !info.get_string("User").is_empty())
|
|
733
767
|
.unwrap_or(false);
|
|
734
768
|
if !logged_in {
|
|
735
|
-
|
|
769
|
+
// #226 follow-up: an unattended nightly run has
|
|
770
|
+
// nobody to log back in when a session expires.
|
|
771
|
+
// SAP_USER/SAP_PASSWORD let us do that ourselves,
|
|
772
|
+
// once per attach - a no-op when they're unset, so
|
|
773
|
+
// attach-only and interactive use are unchanged.
|
|
774
|
+
if !login_attempted {
|
|
775
|
+
Self::try_auto_login(&session);
|
|
776
|
+
}
|
|
777
|
+
return Ok((None, true)); // keep waiting - not logged in yet
|
|
736
778
|
}
|
|
737
|
-
Ok(Some(session))
|
|
779
|
+
Ok((Some(session), false))
|
|
738
780
|
})();
|
|
739
781
|
|
|
740
782
|
match attempt {
|
|
741
|
-
Ok(Some(session)) => {
|
|
783
|
+
Ok((Some(session), _)) => {
|
|
742
784
|
self.session = Some(session);
|
|
743
785
|
return Ok(());
|
|
744
786
|
}
|
|
745
|
-
Ok(None) =>
|
|
787
|
+
Ok((None, at_login_screen)) => {
|
|
788
|
+
opened = !connection.is_empty();
|
|
789
|
+
login_attempted = login_attempted || at_login_screen;
|
|
790
|
+
}
|
|
746
791
|
Err(e) if Instant::now() >= deadline => {
|
|
747
792
|
return Err(DriverError::Uia(format!(
|
|
748
793
|
"{e} — start SAP Logon, log in, and enable scripting \
|
|
@@ -37,6 +37,19 @@ const STEP_TIMEOUT_MS: u64 = 5000;
|
|
|
37
37
|
/// Poll cadence while an auto-waiting assertion is pending.
|
|
38
38
|
const ASSERT_POLL_INTERVAL: Duration = Duration::from_millis(250);
|
|
39
39
|
|
|
40
|
+
/// The precondition wait baked into every targeted step at record time.
|
|
41
|
+
/// `STEP_TIMEOUT_MS` stays the default for everyone - override only when
|
|
42
|
+
/// recording somewhere with real reason to expect more latency (e.g. a
|
|
43
|
+
/// self-hosted CI runner reaching its app over a slower network hop than a
|
|
44
|
+
/// developer's own machine). The replayed value lives in the trace, not
|
|
45
|
+
/// this constant, so this only matters at record time.
|
|
46
|
+
fn step_timeout_ms() -> u64 {
|
|
47
|
+
std::env::var("FLOWPROOF_STEP_TIMEOUT_MS")
|
|
48
|
+
.ok()
|
|
49
|
+
.and_then(|v| v.parse().ok())
|
|
50
|
+
.unwrap_or(STEP_TIMEOUT_MS)
|
|
51
|
+
}
|
|
52
|
+
|
|
40
53
|
#[derive(Debug, thiserror::Error)]
|
|
41
54
|
pub enum RecordError {
|
|
42
55
|
#[error(transparent)]
|
|
@@ -812,7 +825,7 @@ fn step_for(id: usize, intent: &str, app: &str, action: &ResolvedAction) -> Step
|
|
|
812
825
|
vec![]
|
|
813
826
|
} else {
|
|
814
827
|
vec![Condition::ElementExists {
|
|
815
|
-
timeout_ms:
|
|
828
|
+
timeout_ms: step_timeout_ms(),
|
|
816
829
|
selector_ref: None,
|
|
817
830
|
}]
|
|
818
831
|
};
|
|
@@ -1462,10 +1462,17 @@ mod tests {
|
|
|
1462
1462
|
|
|
1463
1463
|
/// Write a trace file the way `record` would, but hand-built so the test
|
|
1464
1464
|
/// needs no real model - a driver-blind cassette on disk.
|
|
1465
|
+
///
|
|
1466
|
+
/// The name carries a nonce as well as the pid. Cargo runs tests as threads
|
|
1467
|
+
/// in ONE process, so a pid-only name is shared by every caller: two of them
|
|
1468
|
+
/// wrote the same file while a third read it, and the reader got
|
|
1469
|
+
/// `EOF while parsing`. That flake failed CI on two unrelated pull requests
|
|
1470
|
+
/// before anyone traced it. Same reason `setup` pairs the pid with
|
|
1471
|
+
/// `mcp_nonce()` a few hundred lines up.
|
|
1465
1472
|
fn write_trace(cassette: Cassette) -> std::path::PathBuf {
|
|
1466
1473
|
let dir = std::env::temp_dir().join("flowproof-agent-flow");
|
|
1467
1474
|
std::fs::create_dir_all(&dir).expect("temp dir");
|
|
1468
|
-
let path = dir.join(format!("trace-{}.json", std::process::id()));
|
|
1475
|
+
let path = dir.join(format!("trace-{}-{}.json", std::process::id(), mcp_nonce()));
|
|
1469
1476
|
let trace = AgentTrace {
|
|
1470
1477
|
app: "agent".into(),
|
|
1471
1478
|
mocks: BTreeMap::new(),
|
|
@@ -1481,6 +1488,56 @@ mod tests {
|
|
|
1481
1488
|
path
|
|
1482
1489
|
}
|
|
1483
1490
|
|
|
1491
|
+
/// Concurrent `write_trace` calls must not collide.
|
|
1492
|
+
///
|
|
1493
|
+
/// This asserts the property directly rather than hoping a race shows up:
|
|
1494
|
+
/// with a pid-only name every thread returns the SAME path, so the
|
|
1495
|
+
/// distinctness assertion fails every time instead of one run in twenty.
|
|
1496
|
+
/// Running the suite repeatedly proved nothing - eight consecutive green
|
|
1497
|
+
/// full-suite runs on a four-core box failed to reproduce the flake that had
|
|
1498
|
+
/// already broken CI twice.
|
|
1499
|
+
#[test]
|
|
1500
|
+
fn concurrent_write_trace_calls_get_their_own_files() {
|
|
1501
|
+
use std::sync::{Arc, Barrier};
|
|
1502
|
+
|
|
1503
|
+
const THREADS: usize = 8;
|
|
1504
|
+
let barrier = Arc::new(Barrier::new(THREADS));
|
|
1505
|
+
let handles: Vec<_> = (0..THREADS)
|
|
1506
|
+
.map(|i| {
|
|
1507
|
+
let barrier = Arc::clone(&barrier);
|
|
1508
|
+
std::thread::spawn(move || {
|
|
1509
|
+
// Start together, to overlap the write as much as possible.
|
|
1510
|
+
barrier.wait();
|
|
1511
|
+
write_trace(neutral_cassette(
|
|
1512
|
+
&format!("prompt {i}"),
|
|
1513
|
+
&format!("reply {i}"),
|
|
1514
|
+
))
|
|
1515
|
+
})
|
|
1516
|
+
})
|
|
1517
|
+
.collect();
|
|
1518
|
+
|
|
1519
|
+
let paths: Vec<_> = handles
|
|
1520
|
+
.into_iter()
|
|
1521
|
+
.map(|h| h.join().expect("thread"))
|
|
1522
|
+
.collect();
|
|
1523
|
+
|
|
1524
|
+
let unique: std::collections::BTreeSet<_> = paths.iter().collect();
|
|
1525
|
+
assert_eq!(
|
|
1526
|
+
unique.len(),
|
|
1527
|
+
THREADS,
|
|
1528
|
+
"every concurrent write_trace needs its own path; got {} distinct of {THREADS}",
|
|
1529
|
+
unique.len()
|
|
1530
|
+
);
|
|
1531
|
+
|
|
1532
|
+
// And each file must be complete: a shared path leaves a reader parsing
|
|
1533
|
+
// a half-written file, which is the `EOF while parsing` the flake showed.
|
|
1534
|
+
for path in &paths {
|
|
1535
|
+
let text = std::fs::read_to_string(path).expect("trace readable");
|
|
1536
|
+
serde_json::from_str::<AgentTrace>(&text)
|
|
1537
|
+
.unwrap_or_else(|e| panic!("{} is not a complete trace: {e}", path.display()));
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1484
1541
|
/// A free localhost port: bind ephemeral, read the number, release it.
|
|
1485
1542
|
/// A tiny race, acceptable in a test - the proxy re-binds it immediately.
|
|
1486
1543
|
fn free_port() -> u16 {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
//! Falsifiability proof for the `audit --since` regression gate (issue #254).
|
|
2
|
+
//!
|
|
3
|
+
//! `audit_record_e2e.rs` proves the gate FIRES: a removed control and a
|
|
4
|
+
//! pass->fail change both exit non-zero. Nothing proves it can DECLINE to
|
|
5
|
+
//! fire. A gate wired to exit non-zero unconditionally would satisfy every
|
|
6
|
+
//! assertion in that file, and CI would go red on every clean run until
|
|
7
|
+
//! somebody stopped believing it.
|
|
8
|
+
//!
|
|
9
|
+
//! That is the shape of a false signal in a gate: not a missed regression, but
|
|
10
|
+
//! a gate that says "regression" so often it stops meaning anything. So the
|
|
11
|
+
//! property under proof here is the discriminating half --
|
|
12
|
+
//! `audit --since` exits ZERO when nothing regressed -- which is what makes
|
|
13
|
+
//! its non-zero exit worth acting on.
|
|
14
|
+
//!
|
|
15
|
+
//! Both layers, as everywhere in this suite:
|
|
16
|
+
//! 1. the diff CONTENT (`audit --since --json`), and
|
|
17
|
+
//! 2. the process EXIT CODE a CI run would branch on.
|
|
18
|
+
//!
|
|
19
|
+
//! A gate that reported an empty diff while still exiting non-zero would pass
|
|
20
|
+
//! a content-only check, and would still break every pipeline that uses it.
|
|
21
|
+
|
|
22
|
+
use std::path::{Path, PathBuf};
|
|
23
|
+
|
|
24
|
+
const FLOWPROOF_BIN: &str = env!("CARGO_BIN_EXE_flowproof");
|
|
25
|
+
|
|
26
|
+
fn fixture(name: &str) -> PathBuf {
|
|
27
|
+
Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
28
|
+
.join("../../tests/falsifiability/fixtures/audit-since")
|
|
29
|
+
.join(name)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fn work_dir(name: &str) -> PathBuf {
|
|
33
|
+
let dir = std::env::temp_dir().join(format!("flowproof-fals-since-{name}"));
|
|
34
|
+
std::fs::remove_dir_all(&dir).ok();
|
|
35
|
+
std::fs::create_dir_all(&dir).expect("work dir");
|
|
36
|
+
dir
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Install a committed record fixture as a real run record, in the layout
|
|
40
|
+
/// `flowproof run` would have written. The directory name is taken from the
|
|
41
|
+
/// record's own `run_id`, so the two can never drift apart.
|
|
42
|
+
fn install(dir: &Path, fixture_name: &str) -> String {
|
|
43
|
+
let body = std::fs::read_to_string(fixture(fixture_name)).expect("read fixture");
|
|
44
|
+
let value: serde_json::Value = serde_json::from_str(&body).expect("fixture is JSON");
|
|
45
|
+
let run_id = value["run_id"].as_str().expect("run_id").to_string();
|
|
46
|
+
let run_dir = dir.join(".flowproof").join("runs").join(&run_id);
|
|
47
|
+
std::fs::create_dir_all(&run_dir).expect("run dir");
|
|
48
|
+
std::fs::write(run_dir.join("report.json"), body).expect("install record");
|
|
49
|
+
run_id
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
fn audit_since(dir: &Path, base: &str) -> std::process::Output {
|
|
53
|
+
std::process::Command::new(FLOWPROOF_BIN)
|
|
54
|
+
.args([
|
|
55
|
+
"audit",
|
|
56
|
+
dir.to_str().expect("utf8"),
|
|
57
|
+
"--since",
|
|
58
|
+
base,
|
|
59
|
+
"--json",
|
|
60
|
+
])
|
|
61
|
+
.output()
|
|
62
|
+
.expect("audit --since")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Two runs with identical controls are not a regression. The gate must stay
|
|
66
|
+
/// silent, and it must exit zero.
|
|
67
|
+
#[test]
|
|
68
|
+
fn an_unchanged_control_set_is_not_a_regression() {
|
|
69
|
+
let dir = work_dir("unchanged");
|
|
70
|
+
let base = install(&dir, "base.report.json");
|
|
71
|
+
install(&dir, "head-unchanged.report.json");
|
|
72
|
+
|
|
73
|
+
let out = audit_since(&dir, &base);
|
|
74
|
+
|
|
75
|
+
// Layer 2: the exit code CI branches on.
|
|
76
|
+
assert!(
|
|
77
|
+
out.status.success(),
|
|
78
|
+
"an unchanged control set must exit zero, or the gate cries wolf on \
|
|
79
|
+
every clean run; stdout={} stderr={}",
|
|
80
|
+
String::from_utf8_lossy(&out.stdout),
|
|
81
|
+
String::from_utf8_lossy(&out.stderr)
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Layer 1: and it must say nothing changed, rather than exiting zero while
|
|
85
|
+
// reporting a diff -- which would be the same defect wearing the other face.
|
|
86
|
+
let diff: serde_json::Value =
|
|
87
|
+
serde_json::from_slice(&out.stdout).expect("audit --since --json is valid JSON");
|
|
88
|
+
for section in ["added", "removed", "changed"] {
|
|
89
|
+
assert_eq!(
|
|
90
|
+
diff[section].as_array().map(Vec::len).unwrap_or(0),
|
|
91
|
+
0,
|
|
92
|
+
"{section} must be empty for an unchanged pair: {diff}"
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
std::fs::remove_dir_all(&dir).ok();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Gaining a control is not a regression. It is reported, because a reviewer
|
|
100
|
+
/// wants to see it, but it must not fail the build: a suite that could never
|
|
101
|
+
/// grow without going red would teach its owners to stop adding controls.
|
|
102
|
+
#[test]
|
|
103
|
+
fn an_added_control_is_reported_but_does_not_fail_the_gate() {
|
|
104
|
+
let dir = work_dir("added");
|
|
105
|
+
let base = install(&dir, "base.report.json");
|
|
106
|
+
install(&dir, "head-added.report.json");
|
|
107
|
+
|
|
108
|
+
let out = audit_since(&dir, &base);
|
|
109
|
+
|
|
110
|
+
assert!(
|
|
111
|
+
out.status.success(),
|
|
112
|
+
"adding a control must not fail the gate; stdout={} stderr={}",
|
|
113
|
+
String::from_utf8_lossy(&out.stdout),
|
|
114
|
+
String::from_utf8_lossy(&out.stderr)
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
let diff: serde_json::Value =
|
|
118
|
+
serde_json::from_slice(&out.stdout).expect("audit --since --json is valid JSON");
|
|
119
|
+
assert_eq!(
|
|
120
|
+
diff["added"][0]["id"], "falsifiability.since.gamma",
|
|
121
|
+
"the addition is still reported: {diff}"
|
|
122
|
+
);
|
|
123
|
+
assert_eq!(
|
|
124
|
+
diff["removed"].as_array().map(Vec::len).unwrap_or(0),
|
|
125
|
+
0,
|
|
126
|
+
"nothing was removed: {diff}"
|
|
127
|
+
);
|
|
128
|
+
assert_eq!(
|
|
129
|
+
diff["changed"].as_array().map(Vec::len).unwrap_or(0),
|
|
130
|
+
0,
|
|
131
|
+
"nothing changed verdict: {diff}"
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
std::fs::remove_dir_all(&dir).ok();
|
|
135
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
//! Falsifiability proof for `control:` verdict mapping (issue #253).
|
|
2
|
+
//!
|
|
3
|
+
//! `audit_record_e2e.rs` proves audit READS a record rather than re-replaying,
|
|
4
|
+
//! and that `--since` exits non-zero on a regression. But every verdict it
|
|
5
|
+
//! asserts is either `capability-error` (a flow skipped by an env gate) or
|
|
6
|
+
//! hand-written into a record fixture. Nothing there runs a control-bearing
|
|
7
|
+
//! flow that genuinely FAILS and checks what verdict the record receives.
|
|
8
|
+
//!
|
|
9
|
+
//! That is the gap this closes, and it is the highest-consequence false green
|
|
10
|
+
//! available: the control map is what a reader trusts when they cannot read
|
|
11
|
+
//! the trace, so a control reporting `pass` over a failed flow would misreport
|
|
12
|
+
//! the one thing the artifact exists to say.
|
|
13
|
+
//!
|
|
14
|
+
//! The red path is built the way `api_pipeline.rs` builds its green one -- a
|
|
15
|
+
//! real loopback server, with the host travelling as `${API_BASE}` so it never
|
|
16
|
+
//! enters the trace. Record against a live endpoint (an honest trace is
|
|
17
|
+
//! minted), then run with the port dead, so the assertion fails for real at
|
|
18
|
+
//! replay rather than being simulated.
|
|
19
|
+
//!
|
|
20
|
+
//! Two layers, deliberately, because the streaming false green of 0.9.0 got
|
|
21
|
+
//! through by being checked at the wrong one:
|
|
22
|
+
//! 1. the VERDICT recorded in `.flowproof/runs/<id>/report.json`, and
|
|
23
|
+
//! re-rendered by `flowproof audit`;
|
|
24
|
+
//! 2. the process EXIT CODE of the real binary.
|
|
25
|
+
//!
|
|
26
|
+
//! A run that failed loudly while recording `verdict: pass` would sail through
|
|
27
|
+
//! a one-layer check.
|
|
28
|
+
|
|
29
|
+
use std::path::{Path, PathBuf};
|
|
30
|
+
|
|
31
|
+
const FLOWPROOF_BIN: &str = env!("CARGO_BIN_EXE_flowproof");
|
|
32
|
+
|
|
33
|
+
/// A port with nothing on it. Chosen by binding and dropping, so the number is
|
|
34
|
+
/// free at this instant rather than merely assumed to be.
|
|
35
|
+
fn dead_base() -> String {
|
|
36
|
+
let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe binds");
|
|
37
|
+
let addr = probe.local_addr().expect("probe addr");
|
|
38
|
+
drop(probe);
|
|
39
|
+
format!("http://{addr}")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Serve `GET /health` -> 200 for up to `requests` requests, then stop.
|
|
43
|
+
///
|
|
44
|
+
/// Deliberately DETACHED and never joined. Joining would block forever the
|
|
45
|
+
/// moment the flow makes fewer probes than the count guessed here -- a hang is
|
|
46
|
+
/// a far worse failure mode for a falsifiability test than an over-generous
|
|
47
|
+
/// server, because a hung test reports nothing at all.
|
|
48
|
+
fn serve(server: tiny_http::Server, requests: usize) {
|
|
49
|
+
std::thread::spawn(move || {
|
|
50
|
+
for _ in 0..requests {
|
|
51
|
+
let Ok(request) = server.recv() else { break };
|
|
52
|
+
let (code, body) = if request.url() == "/health" {
|
|
53
|
+
(200, r#"{"status":"ok"}"#)
|
|
54
|
+
} else {
|
|
55
|
+
(404, r#"{"error":"not found"}"#)
|
|
56
|
+
};
|
|
57
|
+
request
|
|
58
|
+
.respond(tiny_http::Response::from_string(body).with_status_code(code))
|
|
59
|
+
.ok();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// The guilty flow, kept as a committed fixture so it is reviewable as
|
|
65
|
+
/// evidence rather than buried in a string literal.
|
|
66
|
+
fn fixture() -> PathBuf {
|
|
67
|
+
Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
68
|
+
.join("../../tests/falsifiability/fixtures/control-verdict-fail.flow.yaml")
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fn work_dir(name: &str) -> PathBuf {
|
|
72
|
+
let dir = std::env::temp_dir().join(format!("flowproof-fals-control-{name}"));
|
|
73
|
+
std::fs::remove_dir_all(&dir).ok();
|
|
74
|
+
std::fs::create_dir_all(&dir).expect("work dir");
|
|
75
|
+
dir
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// The single `report.json` written under `<dir>/.flowproof/runs`.
|
|
79
|
+
fn find_record(dir: &Path) -> Option<PathBuf> {
|
|
80
|
+
let runs = dir.join(".flowproof").join("runs");
|
|
81
|
+
for entry in std::fs::read_dir(&runs).ok()?.filter_map(Result::ok) {
|
|
82
|
+
let candidate = entry.path().join("report.json");
|
|
83
|
+
if candidate.is_file() {
|
|
84
|
+
return Some(candidate);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
None
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// A control-bearing flow that fails must be recorded as `fail` -- never
|
|
91
|
+
/// `pass`, and never quietly downgraded to `capability-error`, which would
|
|
92
|
+
/// read as "we could not tell" when in fact we could.
|
|
93
|
+
#[test]
|
|
94
|
+
fn a_failing_control_flow_is_recorded_as_fail() {
|
|
95
|
+
let dir = work_dir("verdict");
|
|
96
|
+
let spec = dir.join("control-verdict-fail.flow.yaml");
|
|
97
|
+
std::fs::copy(fixture(), &spec).expect("stage fixture");
|
|
98
|
+
|
|
99
|
+
// --- Record honestly, against a live endpoint. ---
|
|
100
|
+
let server = tiny_http::Server::http("127.0.0.1:0").expect("server binds");
|
|
101
|
+
let live = format!("http://{}", server.server_addr());
|
|
102
|
+
serve(server, 4);
|
|
103
|
+
std::env::set_var("API_BASE", &live);
|
|
104
|
+
|
|
105
|
+
let rec = std::process::Command::new(FLOWPROOF_BIN)
|
|
106
|
+
.arg("record")
|
|
107
|
+
.arg(&spec)
|
|
108
|
+
.current_dir(&dir)
|
|
109
|
+
.env("API_BASE", &live)
|
|
110
|
+
.output()
|
|
111
|
+
.expect("record against the live endpoint");
|
|
112
|
+
assert!(
|
|
113
|
+
rec.status.success(),
|
|
114
|
+
"the recording leg must be honest and green; stdout={} stderr={}",
|
|
115
|
+
String::from_utf8_lossy(&rec.stdout),
|
|
116
|
+
String::from_utf8_lossy(&rec.stderr)
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// --- Now run it with the endpoint gone. The assertion fails for real. ---
|
|
120
|
+
let dead = dead_base();
|
|
121
|
+
let out = std::process::Command::new(FLOWPROOF_BIN)
|
|
122
|
+
.arg("run")
|
|
123
|
+
.arg(&spec)
|
|
124
|
+
.current_dir(&dir)
|
|
125
|
+
.env("API_BASE", &dead)
|
|
126
|
+
.output()
|
|
127
|
+
.expect("run against the dead endpoint");
|
|
128
|
+
|
|
129
|
+
// Layer 2: the exit code CI would see.
|
|
130
|
+
assert!(
|
|
131
|
+
!out.status.success(),
|
|
132
|
+
"a flow whose assertion cannot pass must exit non-zero; stdout={} stderr={}",
|
|
133
|
+
String::from_utf8_lossy(&out.stdout),
|
|
134
|
+
String::from_utf8_lossy(&out.stderr)
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// Layer 1: the verdict the record actually carries.
|
|
138
|
+
let record = find_record(&dir).unwrap_or_else(|| {
|
|
139
|
+
panic!(
|
|
140
|
+
"a failing run must still write a run record, or the control \
|
|
141
|
+
vanishes from the audit map instead of reporting fail; stdout={} stderr={}",
|
|
142
|
+
String::from_utf8_lossy(&out.stdout),
|
|
143
|
+
String::from_utf8_lossy(&out.stderr)
|
|
144
|
+
)
|
|
145
|
+
});
|
|
146
|
+
let body = std::fs::read_to_string(&record).expect("read record");
|
|
147
|
+
let value: serde_json::Value = serde_json::from_str(&body).expect("record is JSON");
|
|
148
|
+
let verdict = value["flows"]
|
|
149
|
+
.as_array()
|
|
150
|
+
.expect("flows array")
|
|
151
|
+
.iter()
|
|
152
|
+
.find_map(|f| {
|
|
153
|
+
let c = &f["control"];
|
|
154
|
+
(c["id"] == "falsifiability.control.verdict").then(|| c["verdict"].clone())
|
|
155
|
+
})
|
|
156
|
+
.unwrap_or_else(|| panic!("the record folds the flow's control: {body}"));
|
|
157
|
+
|
|
158
|
+
assert_eq!(
|
|
159
|
+
verdict, "fail",
|
|
160
|
+
"a control whose flow failed must be recorded as fail, not {verdict} -- \
|
|
161
|
+
this is the false green the audit map cannot afford: {body}"
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// And `audit` must re-render that verdict rather than soften it.
|
|
165
|
+
let audit = std::process::Command::new(FLOWPROOF_BIN)
|
|
166
|
+
.args(["audit", dir.to_str().expect("utf8"), "--json"])
|
|
167
|
+
.output()
|
|
168
|
+
.expect("audit --json");
|
|
169
|
+
let map: serde_json::Value =
|
|
170
|
+
serde_json::from_slice(&audit.stdout).expect("audit --json is valid JSON");
|
|
171
|
+
let rendered = map["controls"]
|
|
172
|
+
.as_array()
|
|
173
|
+
.expect("controls array")
|
|
174
|
+
.iter()
|
|
175
|
+
.find(|c| c["id"] == "falsifiability.control.verdict")
|
|
176
|
+
.unwrap_or_else(|| {
|
|
177
|
+
panic!(
|
|
178
|
+
"audit names the control: {}",
|
|
179
|
+
String::from_utf8_lossy(&audit.stdout)
|
|
180
|
+
)
|
|
181
|
+
})
|
|
182
|
+
.clone();
|
|
183
|
+
assert_eq!(
|
|
184
|
+
rendered["verdict"], "fail",
|
|
185
|
+
"audit renders the recorded verdict unchanged"
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
std::fs::remove_dir_all(&dir).ok();
|
|
189
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
//! End-to-end against a REAL SAP GUI session
|
|
2
|
-
//!
|
|
3
|
-
//!
|
|
1
|
+
//! End-to-end against a REAL SAP GUI session: on the self-hosted `sap`
|
|
2
|
+
//! runner via .github/workflows/sap-e2e.yml (see #32), or maintainer-run
|
|
3
|
+
//! locally with Windows, SAP GUI installed, logged in, scripting enabled
|
|
4
|
+
//! (`sapgui/user_scripting = TRUE`), and FLOWPROOF_E2E_SAP=1.
|
|
4
5
|
//!
|
|
5
6
|
//! The flow only navigates and reads — it types a transaction code and
|
|
6
7
|
//! asserts on the status/title, touching no business data.
|
|
@@ -10,13 +11,19 @@
|
|
|
10
11
|
use flowproof_adapters::sap_com::SapAppDriver;
|
|
11
12
|
use flowproof_agent::FlowSpec;
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
";
|
|
14
|
+
/// A logged-in session already exists on every machine this test runs on
|
|
15
|
+
/// today (attach-only, `connection` omitted). SAP_CONNECTION lets an
|
|
16
|
+
/// unattended nightly run open one instead, should the runner ever come up
|
|
17
|
+
/// without one — the value is carried as `${VAR}` and resolved at launch
|
|
18
|
+
/// time, so it never reaches the trace.
|
|
19
|
+
fn spec_yaml() -> String {
|
|
20
|
+
let mut spec = String::from("name: Navigate to session status\napp: sap\n");
|
|
21
|
+
if std::env::var("SAP_CONNECTION").is_ok() {
|
|
22
|
+
spec.push_str("connection: ${SAP_CONNECTION}\n");
|
|
23
|
+
}
|
|
24
|
+
spec.push_str("steps:\n - Go to /nSESSION_MANAGER\n - assert: page shows Session\n");
|
|
25
|
+
spec
|
|
26
|
+
}
|
|
20
27
|
|
|
21
28
|
#[test]
|
|
22
29
|
fn navigates_a_real_sap_session() {
|
|
@@ -31,7 +38,7 @@ fn navigates_a_real_sap_session() {
|
|
|
31
38
|
std::fs::create_dir_all(&dir).expect("temp dir");
|
|
32
39
|
let trace_path = dir.join("session.trace.jsonl");
|
|
33
40
|
|
|
34
|
-
let spec = FlowSpec::parse(
|
|
41
|
+
let spec = FlowSpec::parse(&spec_yaml()).expect("spec parses");
|
|
35
42
|
|
|
36
43
|
let mut driver = SapAppDriver::new().expect("COM engine");
|
|
37
44
|
flowproof_agent::record(&spec, &mut driver, &trace_path).expect("records against live SAP");
|
|
@@ -118,12 +118,45 @@ class Collection:
|
|
|
118
118
|
Item = ElementAt
|
|
119
119
|
|
|
120
120
|
|
|
121
|
+
class SessionInfo:
|
|
122
|
+
"""`GuiSession.Info`, and specifically its `User`.
|
|
123
|
+
|
|
124
|
+
The engine uses a non-empty `User` to tell a logged-in session from one
|
|
125
|
+
sitting at the SAP login screen: a session object exists as soon as its
|
|
126
|
+
window opens, login screen included, so existence alone proves nothing.
|
|
127
|
+
Real SAP reports transaction `S000` and an empty user until someone
|
|
128
|
+
authenticates.
|
|
129
|
+
|
|
130
|
+
The simulator therefore has to present a session that is actually logged
|
|
131
|
+
in, because that is what the test claims to exercise - "record through the
|
|
132
|
+
production COM engine against a running, logged-in session". A simulator
|
|
133
|
+
that reported an empty user would be asking the engine to relax the check
|
|
134
|
+
rather than asking the test to be honest.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
_public_methods_ = []
|
|
138
|
+
_public_attrs_ = ["User", "Client", "Transaction", "SystemName"]
|
|
139
|
+
|
|
140
|
+
def __init__(self, user="FLOWPROOF", client="001", transaction="VA01",
|
|
141
|
+
system="SIM"):
|
|
142
|
+
self.User = user
|
|
143
|
+
self.Client = client
|
|
144
|
+
self.Transaction = transaction
|
|
145
|
+
self.SystemName = system
|
|
146
|
+
|
|
147
|
+
|
|
121
148
|
class Session(Component):
|
|
122
149
|
_public_methods_ = Component._public_methods_ + ["FindById"]
|
|
150
|
+
_public_attrs_ = Component._public_attrs_ + ["Info"]
|
|
123
151
|
|
|
124
152
|
def __init__(self, screen):
|
|
125
153
|
Component.__init__(self, screen, "ses", "GuiSession", "ses[0]")
|
|
126
154
|
self.Id = "/app/con[0]/ses[0]"
|
|
155
|
+
# Wrapped, like every other nested object here (`Children`, `child`,
|
|
156
|
+
# `connection`). An unwrapped Python instance is not dispatchable, so
|
|
157
|
+
# the engine's `get_disp("Info")` would fail and the session would look
|
|
158
|
+
# exactly as "not logged in" as before.
|
|
159
|
+
self.Info = wrap(SessionInfo())
|
|
127
160
|
|
|
128
161
|
def FindById(self, element_id):
|
|
129
162
|
element = self._screen.by_id.get(str(element_id))
|
|
@@ -4,8 +4,8 @@ build-backend = "maturin"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "flowproof"
|
|
7
|
-
version = "0.9.
|
|
8
|
-
description = "Deterministic
|
|
7
|
+
version = "0.9.1"
|
|
8
|
+
description = "Deterministic evidence and control-regression gating for AI agents and the SAP, web and Citrix systems they drive. Record once, replay with zero LLM calls."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
11
11
|
requires-python = ">=3.9"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/tests/fixtures/sample.trace.jsonl
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|