From 236eccf49ae598fac3e496bfa1c20407e0d46404 Mon Sep 17 00:00:00 2001 From: OpenCode Date: Fri, 10 Jul 2026 16:32:20 +0700 Subject: [PATCH] feat: Phase H H5 no-silent-network policy -- NSN- schema detects undeclared adapter network calls and sequence-data exposure risk, protecting candidate privacy --- docs/research/NEXT_100_PR_MAP.md | 2 +- .../simulation/no_silent_network_policy.py | 184 +++++++++++ .../test_no_silent_network_policy.py | 302 ++++++++++++++++++ tests/test_test_count_regression.py | 2 +- 4 files changed, 488 insertions(+), 2 deletions(-) create mode 100644 src/openamp_foundry/simulation/no_silent_network_policy.py create mode 100644 tests/simulation/test_no_silent_network_policy.py diff --git a/docs/research/NEXT_100_PR_MAP.md b/docs/research/NEXT_100_PR_MAP.md index 9c5677f3..3c2ecd02 100644 --- a/docs/research/NEXT_100_PR_MAP.md +++ b/docs/research/NEXT_100_PR_MAP.md @@ -147,7 +147,7 @@ Make simulation useful or harmless. | H2 | Add simulation-result schema (complete). — schemas/simulation_result.schema.json: JSON schema for simulation proxy outputs; simulation/result_validator.py validates against schema; make validate-simulation-result-schema target; tests/simulation/test_result_validator.py. | Prevents undocumented proxy output. | B/C | | H3 | Add per-module cheapest-baseline declaration (complete). — simulation/baseline_registry.py: per-module cheapest-baseline scores for cheap-enemy comparison; make simulation-baseline-check target; tests/simulation/test_baseline_registry.py. | Forces enemy comparison. | C | | H4 | Add fail-closed adapter integration tests (complete). — simulation/adapter_gate.py: fail-closed integration gate that blocks if adapter returns error or timeout; make adapter-gate-check target; tests/simulation/test_adapter_gate.py. | Avoids hidden external failures. | B/C | -| H5 | Add no-silent-network policy test for adapters. | Protects sequence privacy. | D | +| H5 | Add no-silent-network policy test for adapters. | Protects sequence privacy. | D | DONE | | H6 | Add simulation uncertainty calibration report (complete). — SUC- schema: 14 fields, 10 validation rules, VALID_SIMULATION_MODULES (7 values), VALID_CALIBRATION_METHODS (6 values), OVERCONFIDENCE_THRESHOLD=0.15, MIN_SAMPLES_FOR_CALIBRATION=10; makes module confidence interval calibration machine-auditable; 63 tests. | Makes uncertainty inspectable. | C | | H7 | Add documentation that failed simulation stays useful as negative evidence (complete). — docs/FAILED_SIMULATION_AS_NEGATIVE_EVIDENCE.md: cultural standard requiring NRR- record for every failure; agent MUST NOT rules; anti-selective-reporting enforcement. | Cultural standard. | A | | H8 | Add `weighted` integration dry-run report, still blocked by gate (complete). — WDR- schema: 15 fields, 12 validation rules, results_applied_to_ranking=False enforcement, GATE_CLOSED_DISCLAIMER required, counterfactual-only; 63 tests. | Shows what would change without applying. | C | diff --git a/src/openamp_foundry/simulation/no_silent_network_policy.py b/src/openamp_foundry/simulation/no_silent_network_policy.py new file mode 100644 index 00000000..e8891990 --- /dev/null +++ b/src/openamp_foundry/simulation/no_silent_network_policy.py @@ -0,0 +1,184 @@ +"""NSN- no-silent-network policy schema for simulation adapters. + +Sequence data is sensitive. An adapter that silently sends candidate +sequences to an external endpoint could expose proprietary sequences +without any audit trail. + +This policy requires that: + 1. Every external network call an adapter can make is declared upfront. + 2. Any call NOT in the declared list is flagged as undeclared. + 3. Any call that could transmit sequence data is flagged as a + sequence-exposure risk regardless of declaration. + +In dry-lab mode the expected network call count is zero. +A compliant adapter either makes no network calls or makes only +explicitly declared calls with no sequence-bearing endpoints. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +VALID_NSN_VERDICTS: frozenset[str] = frozenset({ + "compliant", + "undeclared_calls_detected", + "sequence_exposure_risk", +}) + +SEQUENCE_EXPOSURE_KEYWORDS: tuple[str, ...] = ( + "sequence", + "seq", + "fasta", + "peptide", + "protein", + "amino", + "residue", +) + + +@dataclass +class NoSilentNetworkPolicy: + nsn_id: str + adapter_id: str + pipeline_version: str + declared_network_calls: list[str] + observed_network_calls: list[str] + undeclared_calls: list[str] + undeclared_calls_detected: bool + sequence_exposure_risk: bool + policy_verdict: str + dry_lab_only: bool + limitations: list[str] + created_at: str + + +def _has_sequence_exposure_risk(calls: list[str]) -> bool: + for call in calls: + call_lower = call.lower() + if any(kw in call_lower for kw in SEQUENCE_EXPOSURE_KEYWORDS): + return True + return False + + +def validate_no_silent_network_policy(nsn: NoSilentNetworkPolicy) -> None: + if not nsn.nsn_id.startswith("NSN-"): + raise ValueError(f"nsn_id must start with 'NSN-': {nsn.nsn_id!r}") + if not nsn.adapter_id: + raise ValueError("adapter_id must be non-empty") + if not nsn.pipeline_version: + raise ValueError("pipeline_version must be non-empty") + if nsn.policy_verdict not in VALID_NSN_VERDICTS: + raise ValueError( + f"policy_verdict {nsn.policy_verdict!r} not in VALID_NSN_VERDICTS" + ) + declared_set = set(nsn.declared_network_calls) + expected_undeclared = [c for c in nsn.observed_network_calls if c not in declared_set] + if sorted(nsn.undeclared_calls) != sorted(expected_undeclared): + raise ValueError("undeclared_calls inconsistent with observed/declared network calls") + expected_undeclared_flag = len(nsn.undeclared_calls) > 0 + if nsn.undeclared_calls_detected != expected_undeclared_flag: + raise ValueError( + "undeclared_calls_detected inconsistent with undeclared_calls" + ) + all_calls = nsn.declared_network_calls + nsn.observed_network_calls + expected_exposure = _has_sequence_exposure_risk(all_calls) + if nsn.sequence_exposure_risk != expected_exposure: + raise ValueError( + "sequence_exposure_risk inconsistent with network call endpoints" + ) + if nsn.undeclared_calls_detected and nsn.policy_verdict != "undeclared_calls_detected": + raise ValueError( + "policy_verdict must be 'undeclared_calls_detected' when undeclared calls present" + ) + if ( + nsn.sequence_exposure_risk + and not nsn.undeclared_calls_detected + and nsn.policy_verdict != "sequence_exposure_risk" + ): + raise ValueError( + "policy_verdict must be 'sequence_exposure_risk' when sequence exposure risk present" + ) + if ( + not nsn.undeclared_calls_detected + and not nsn.sequence_exposure_risk + and nsn.policy_verdict != "compliant" + ): + raise ValueError( + "policy_verdict must be 'compliant' when no undeclared calls and no exposure risk" + ) + if not nsn.dry_lab_only: + raise ValueError("dry_lab_only must be True") + if not nsn.limitations: + raise ValueError("limitations must be non-empty") + if not nsn.created_at: + raise ValueError("created_at must be non-empty") + + +def _compute_verdict( + undeclared_calls_detected: bool, + sequence_exposure_risk: bool, +) -> str: + if undeclared_calls_detected: + return "undeclared_calls_detected" + if sequence_exposure_risk: + return "sequence_exposure_risk" + return "compliant" + + +def build_no_silent_network_policy( + *, + nsn_id: str, + adapter_id: str, + pipeline_version: str, + declared_network_calls: list[str] | None = None, + observed_network_calls: list[str] | None = None, + limitations: list[str], + created_at: str, +) -> NoSilentNetworkPolicy: + """Build a NoSilentNetworkPolicy. + + undeclared_calls, undeclared_calls_detected, sequence_exposure_risk, + and policy_verdict are all auto-computed. + """ + declared = list(declared_network_calls) if declared_network_calls else [] + observed = list(observed_network_calls) if observed_network_calls else [] + declared_set = set(declared) + undeclared = [c for c in observed if c not in declared_set] + undeclared_detected = len(undeclared) > 0 + all_calls = declared + observed + exposure_risk = _has_sequence_exposure_risk(all_calls) + verdict = _compute_verdict(undeclared_detected, exposure_risk) + nsn = NoSilentNetworkPolicy( + nsn_id=nsn_id, + adapter_id=adapter_id, + pipeline_version=pipeline_version, + declared_network_calls=declared, + observed_network_calls=observed, + undeclared_calls=undeclared, + undeclared_calls_detected=undeclared_detected, + sequence_exposure_risk=exposure_risk, + policy_verdict=verdict, + dry_lab_only=True, + limitations=limitations, + created_at=created_at, + ) + validate_no_silent_network_policy(nsn) + return nsn + + +def format_no_silent_network_policy(nsn: NoSilentNetworkPolicy) -> str: + lines = [ + f"No-Silent-Network Policy — {nsn.nsn_id}", + f"Adapter: {nsn.adapter_id} | Pipeline: {nsn.pipeline_version}", + f"Verdict: {nsn.policy_verdict}", + f"Declared calls: {len(nsn.declared_network_calls)}", + f"Observed calls: {len(nsn.observed_network_calls)}", + f"Undeclared calls detected: {nsn.undeclared_calls_detected}", + f"Sequence exposure risk: {nsn.sequence_exposure_risk}", + ] + if nsn.undeclared_calls: + lines.append(f"Undeclared calls: {', '.join(nsn.undeclared_calls)}") + lines.append(f"Limitations: {'; '.join(nsn.limitations)}") + lines.append(f"Created: {nsn.created_at}") + lines.append(f"dry_lab_only: {nsn.dry_lab_only}") + return "\n".join(lines) diff --git a/tests/simulation/test_no_silent_network_policy.py b/tests/simulation/test_no_silent_network_policy.py new file mode 100644 index 00000000..12ea9943 --- /dev/null +++ b/tests/simulation/test_no_silent_network_policy.py @@ -0,0 +1,302 @@ +"""Tests for NSN- no-silent-network policy schema.""" + +import pytest +from openamp_foundry.simulation.no_silent_network_policy import ( + NoSilentNetworkPolicy, + VALID_NSN_VERDICTS, + SEQUENCE_EXPOSURE_KEYWORDS, + build_no_silent_network_policy, + format_no_silent_network_policy, + validate_no_silent_network_policy, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _build(**kwargs): + defaults = dict( + nsn_id="NSN-001", + adapter_id="ADAPTER-membrane-v1", + pipeline_version="v1.0", + declared_network_calls=[], + observed_network_calls=[], + limitations=["dry-lab only"], + created_at="2026-07-10", + ) + defaults.update(kwargs) + return build_no_silent_network_policy(**defaults) + + +# --------------------------------------------------------------------------- +# 1. Constants +# --------------------------------------------------------------------------- + + +def test_valid_nsn_verdicts_is_frozenset(): + assert isinstance(VALID_NSN_VERDICTS, frozenset) + + +def test_valid_nsn_verdicts_contains_compliant(): + assert "compliant" in VALID_NSN_VERDICTS + + +def test_valid_nsn_verdicts_contains_undeclared_calls_detected(): + assert "undeclared_calls_detected" in VALID_NSN_VERDICTS + + +def test_valid_nsn_verdicts_contains_sequence_exposure_risk(): + assert "sequence_exposure_risk" in VALID_NSN_VERDICTS + + +def test_sequence_exposure_keywords_is_tuple(): + assert isinstance(SEQUENCE_EXPOSURE_KEYWORDS, tuple) + + +def test_sequence_exposure_keywords_contains_sequence(): + assert "sequence" in SEQUENCE_EXPOSURE_KEYWORDS + + +def test_sequence_exposure_keywords_contains_fasta(): + assert "fasta" in SEQUENCE_EXPOSURE_KEYWORDS + + +def test_sequence_exposure_keywords_contains_peptide(): + assert "peptide" in SEQUENCE_EXPOSURE_KEYWORDS + + +# --------------------------------------------------------------------------- +# 2. build happy paths +# --------------------------------------------------------------------------- + + +def test_build_returns_no_silent_network_policy(): + assert isinstance(_build(), NoSilentNetworkPolicy) + + +def test_build_nsn_id_stored(): + assert _build().nsn_id == "NSN-001" + + +def test_build_adapter_id_stored(): + assert _build().adapter_id == "ADAPTER-membrane-v1" + + +def test_build_pipeline_version_stored(): + assert _build().pipeline_version == "v1.0" + + +def test_build_dry_lab_only_true(): + assert _build().dry_lab_only is True + + +def test_build_no_calls_gives_compliant(): + r = _build(declared_network_calls=[], observed_network_calls=[]) + assert r.policy_verdict == "compliant" + + +def test_build_declared_calls_observed_gives_compliant(): + r = _build( + declared_network_calls=["https://api.example.com/score"], + observed_network_calls=["https://api.example.com/score"], + ) + assert r.policy_verdict == "compliant" + + +def test_build_undeclared_observed_call_gives_undeclared_calls_detected(): + r = _build( + declared_network_calls=[], + observed_network_calls=["https://external.example.com"], + ) + assert r.policy_verdict == "undeclared_calls_detected" + + +def test_build_sequence_in_declared_gives_sequence_exposure_risk(): + r = _build( + declared_network_calls=["https://api.example.com/sequence"], + observed_network_calls=[], + ) + assert r.policy_verdict == "sequence_exposure_risk" + + +def test_build_fasta_in_observed_gives_sequence_exposure_risk(): + r = _build( + declared_network_calls=["https://api.example.com/fasta"], + observed_network_calls=["https://api.example.com/fasta"], + ) + assert r.policy_verdict == "sequence_exposure_risk" + + +def test_build_undeclared_call_wins_over_exposure_risk(): + r = _build( + declared_network_calls=[], + observed_network_calls=["https://api.example.com/sequence"], + ) + assert r.policy_verdict == "undeclared_calls_detected" + + +def test_build_undeclared_calls_detected_false_when_compliant(): + assert _build().undeclared_calls_detected is False + + +def test_build_undeclared_calls_detected_true_when_undeclared(): + r = _build(observed_network_calls=["https://unknown.example.com"]) + assert r.undeclared_calls_detected is True + + +def test_build_sequence_exposure_risk_false_when_no_keywords(): + r = _build( + declared_network_calls=["https://api.example.com/score"], + observed_network_calls=["https://api.example.com/score"], + ) + assert r.sequence_exposure_risk is False + + +def test_build_sequence_exposure_risk_true_with_keyword(): + r = _build(declared_network_calls=["https://api.example.com/peptide"]) + assert r.sequence_exposure_risk is True + + +def test_build_undeclared_calls_list_populated(): + r = _build(observed_network_calls=["https://unknown.example.com"]) + assert "https://unknown.example.com" in r.undeclared_calls + + +def test_build_undeclared_calls_list_empty_when_compliant(): + assert _build().undeclared_calls == [] + + +def test_build_limitations_stored(): + assert _build().limitations == ["dry-lab only"] + + +def test_build_created_at_stored(): + assert _build().created_at == "2026-07-10" + + +def test_build_peptide_keyword_case_insensitive(): + r = _build(declared_network_calls=["https://api.example.com/PEPTIDE"]) + assert r.sequence_exposure_risk is True + + +def test_build_amino_keyword_triggers_risk(): + r = _build(declared_network_calls=["https://api.example.com/amino-acid"]) + assert r.sequence_exposure_risk is True + + +# --------------------------------------------------------------------------- +# 3. validate rejection cases +# --------------------------------------------------------------------------- + + +def test_validate_rejects_bad_nsn_id_prefix(): + with pytest.raises(ValueError, match="NSN-"): + _build(nsn_id="BAD-001") + + +def test_validate_rejects_empty_adapter_id(): + with pytest.raises(ValueError, match="adapter_id"): + _build(adapter_id="") + + +def test_validate_rejects_empty_pipeline_version(): + with pytest.raises(ValueError): + _build(pipeline_version="") + + +def test_validate_rejects_invalid_policy_verdict(): + nsn = _build() + nsn.policy_verdict = "UNKNOWN" + with pytest.raises(ValueError, match="policy_verdict"): + validate_no_silent_network_policy(nsn) + + +def test_validate_rejects_undeclared_calls_mismatch(): + nsn = _build() + nsn.undeclared_calls = ["https://fake.com"] + with pytest.raises(ValueError, match="undeclared_calls"): + validate_no_silent_network_policy(nsn) + + +def test_validate_rejects_undeclared_calls_detected_mismatch(): + nsn = _build() + nsn.undeclared_calls_detected = True + with pytest.raises(ValueError, match="undeclared_calls_detected"): + validate_no_silent_network_policy(nsn) + + +def test_validate_rejects_sequence_exposure_risk_mismatch(): + nsn = _build() + nsn.sequence_exposure_risk = True + with pytest.raises(ValueError, match="sequence_exposure_risk"): + validate_no_silent_network_policy(nsn) + + +def test_validate_rejects_wrong_verdict_when_undeclared(): + nsn = _build() + nsn.observed_network_calls = ["https://unknown.com"] + nsn.undeclared_calls = ["https://unknown.com"] + nsn.undeclared_calls_detected = True + nsn.policy_verdict = "compliant" + with pytest.raises(ValueError, match="undeclared_calls_detected"): + validate_no_silent_network_policy(nsn) + + +def test_validate_rejects_dry_lab_only_false(): + nsn = _build() + nsn.dry_lab_only = False + with pytest.raises(ValueError, match="dry_lab_only"): + validate_no_silent_network_policy(nsn) + + +def test_validate_rejects_empty_limitations(): + with pytest.raises(ValueError, match="limitations"): + _build(limitations=[]) + + +def test_validate_rejects_empty_created_at(): + with pytest.raises(ValueError): + _build(created_at="") + + +# --------------------------------------------------------------------------- +# 4. format +# --------------------------------------------------------------------------- + + +def test_format_contains_nsn_id(): + assert "NSN-001" in format_no_silent_network_policy(_build()) + + +def test_format_contains_adapter_id(): + assert "ADAPTER-membrane-v1" in format_no_silent_network_policy(_build()) + + +def test_format_contains_pipeline_version(): + assert "v1.0" in format_no_silent_network_policy(_build()) + + +def test_format_contains_verdict(): + assert "compliant" in format_no_silent_network_policy(_build()) + + +def test_format_contains_undeclared_calls_detected(): + assert "False" in format_no_silent_network_policy(_build()) + + +def test_format_contains_undeclared_call_when_detected(): + r = _build(observed_network_calls=["https://unknown.example.com"]) + assert "https://unknown.example.com" in format_no_silent_network_policy(r) + + +def test_format_contains_limitations(): + assert "dry-lab only" in format_no_silent_network_policy(_build()) + + +def test_format_contains_dry_lab_only(): + assert "dry_lab_only: True" in format_no_silent_network_policy(_build()) + + +def test_format_is_string(): + assert isinstance(format_no_silent_network_policy(_build()), str) diff --git a/tests/test_test_count_regression.py b/tests/test_test_count_regression.py index bc0873cc..892fbb9e 100644 --- a/tests/test_test_count_regression.py +++ b/tests/test_test_count_regression.py @@ -4,7 +4,7 @@ import sys import math -BASELINE = 11034 +BASELINE = 11083 def test_test_count_regression():