From 7553635b870beea0fc4ae69089fd0ce33207fb26 Mon Sep 17 00:00:00 2001 From: Jim-jimu <49069997+bmh201708@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:40:26 +0800 Subject: [PATCH] fix(external-evidence): reject duplicate admitted source refs Signed-off-by: Jim-jimu <49069997+bmh201708@users.noreply.github.com> --- .../capabilities/external_evidence.ts | 4 + .../test_external_evidence_cli.py | 81 +++++++++++++++++++ .../external_evidence_research.test.ts | 49 +++++++++++ 3 files changed, 134 insertions(+) diff --git a/loopx/control_plane/capabilities/external_evidence.ts b/loopx/control_plane/capabilities/external_evidence.ts index 13c24f1c7e..cb60b6c5df 100644 --- a/loopx/control_plane/capabilities/external_evidence.ts +++ b/loopx/control_plane/capabilities/external_evidence.ts @@ -448,6 +448,10 @@ export function evaluateExternalEvidenceAdmission(params: JsonObject): JsonObjec const admittedRefs = decision.admitted_source_refs === undefined ? [] : boundedStrings(decision.admitted_source_refs, "decision.admitted_source_refs", 64, 2048); + requireThat( + new Set(admittedRefs).size === admittedRefs.length, + "decision.admitted_source_refs must be unique", + ); const availableRefs = new Set(sources.map((source) => source.source_ref as string)); requireThat( admittedRefs.every((sourceRef) => availableRefs.has(sourceRef)), diff --git a/tests/capabilities/test_external_evidence_cli.py b/tests/capabilities/test_external_evidence_cli.py index 581c323b6a..c17db7a4f9 100644 --- a/tests/capabilities/test_external_evidence_cli.py +++ b/tests/capabilities/test_external_evidence_cli.py @@ -4,9 +4,13 @@ import json import subprocess import sys +import tempfile from pathlib import Path +import pytest + from loopx.capabilities.external_research import cli +from loopx.control_plane.effect_runtime import restart_effect_runtime def _print_payload(payload, _format, _renderer): @@ -349,3 +353,80 @@ def test_source_cli_discovers_inventory_without_claiming_readiness( assert payload["summary"]["connector_count"] == payload["summary"]["provider_count"] assert payload["summary"]["ready_count"] == 0 assert payload["truth_contract"]["execution_observed"] is False + + +@pytest.mark.parametrize("duplicate", [ + "https://example.com/original", " https://example.com/original ", +]) +def test_source_cli_rejects_duplicate_admission_and_accepts_corrected_input( + tmp_path: Path, monkeypatch, duplicate: str, +) -> None: + for variable in ("TMPDIR", "TEMP", "TMP"): + monkeypatch.setenv(variable, str(tmp_path)) + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + monkeypatch.setenv("LOOPX_USAGE_PING", "0") + + def run(*args: str): + result = subprocess.run( + [sys.executable, "-m", "loopx.entrypoint", "--runtime-root", + str(tmp_path / "runtime"), "--registry", str(tmp_path / "registry.json"), + "external-evidence", *args, "--format", "json"], + cwd=Path(__file__).resolve().parents[2], capture_output=True, + text=True, encoding="utf-8", check=False, timeout=30, + ) + return result.returncode, json.loads(result.stdout) + + def save(name: str, payload: dict) -> str: + path = tmp_path / name + path.write_text(json.dumps(payload), encoding="utf-8") + return str(path) + + try: + providers = save("providers.json", {"providers": [{ + "provider_id": "host:external-research", "provider_kind": "method", + "protocol": "external_evidence_research_v0", "declared": True, + "installed": True, "enabled": True, "ready": True, + "unavailable_reason": None, + }]}) + code, plan = run( + "plan", "--objective", "Inspect public evidence", "--user-activity", + "Choose a provider", "--decision", "Whether to adopt", + "--evidence-kind", "current_behavior", "--provider-inventory-json", providers, + ) + assert code == 0, plan + source_ref = "https://example.com/original" + receipt = { + "schema_version": "loopx_external_evidence_receipt_v0", + "plan_id": plan["plan_id"], "request_id": plan["request"]["request_id"], + "provider_id": "host:external-research", "provider_kind": "method", + "status": "succeeded", "summary": "Synthetic public evidence", + "completed_at": "2026-09-28T00:01:00Z", "sources": [{ + "source_ref": source_ref, "source_family": "public-fixture", + "basis": "observed", "finding": "The fixture supports the decision.", + "accessed_at": "2026-09-28T00:00:00Z", + "content_digest": "sha256:" + "a" * 64, + }], + } + admit_args = ( + "admit", "--plan-json", save("plan.json", plan), + "--receipt-json", save("receipt.json", receipt), + "--decision", "admit", "--reason", "Direct evidence", + "--admit-source", source_ref, + ) + code, rejected = run(*admit_args, "--admit-source", duplicate) + assert code == 1, rejected + assert rejected["status"] == "invalid_request" + assert "decision.admitted_source_refs must be unique" in rejected["error"] + assert "admission_id" not in rejected + + code, admission = run(*admit_args) + assert code == 0, admission + assert admission["admitted_source_refs"] == [source_ref] + code, retirement = run( + "retire", "--admission-json", save("admission.json", admission), + "--downstream-source", source_ref, + ) + assert code == 0, retirement + assert retirement["status"] == "retire_ready" + finally: + restart_effect_runtime() diff --git a/tests/control_plane_ts/external_evidence_research.test.ts b/tests/control_plane_ts/external_evidence_research.test.ts index 0b84cac7a3..f1a0993893 100644 --- a/tests/control_plane_ts/external_evidence_research.test.ts +++ b/tests/control_plane_ts/external_evidence_research.test.ts @@ -245,6 +245,55 @@ test("admits exact source refs and exposes only compact provenance", () => { assert.equal(Object.hasOwn(projection, "raw_content"), false); }); +for (const duplicate of [ + "https://example.com/original", + " https://example.com/original ", +]) { + test(`admission rejects duplicate source refs after normalization: ${JSON.stringify(duplicate)}`, () => { + const currentPlan = plan(); + assert.throws( + () => evaluateExternalEvidenceAdmission({ + plan: currentPlan, + receipt: receipt(currentPlan), + decision: { + disposition: "admit", + reason: "Direct evidence must yield a readable admission.", + admitted_source_refs: ["https://example.com/original", duplicate], + }, + }), + /decision\.admitted_source_refs must be unique/, + ); + }); +} + +test("distinct source selections remain readable through retirement", () => { + const currentPlan = plan(); + const currentReceipt = receipt(currentPlan); + const first = "https://example.com/original"; + const second = "https://example.com/second"; + currentReceipt.sources.push({ ...currentReceipt.sources[0], source_ref: second }); + for (const refs of [[second], [second, first]]) { + const admission = evaluateExternalEvidenceAdmission({ + plan: currentPlan, + receipt: currentReceipt, + decision: { + disposition: "admit", + reason: "Selected sources answer the question.", + admitted_source_refs: refs, + }, + }); + assert.deepEqual(admission.admitted_source_refs, refs); + assert.equal( + projectExternalEvidenceRetirement({ admission, downstream_source_refs: [] }).status, + "retained", + ); + assert.equal( + projectExternalEvidenceRetirement({ admission, downstream_source_refs: refs }).status, + "retire_ready", + ); + } +}); + test("admission fails closed on stale plan identity and local file provenance", () => { const currentPlan = plan(); assert.throws(