From 11105770a867a5a1ce18d1655b03c93cbd68855b Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 00:28:09 -0700 Subject: [PATCH 01/41] feat: add Proof Before Action workflow --- README.md | 6 + docs/OUTPUT-CONTRACT.md | 31 + docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md | 79 +++ docs/PROOF-BEFORE-ACTION.md | 145 ++++ examples/proof-before-action.yaml | 15 + pyproject.toml | 1 + src/mcp_audit/proof_capsule.py | 444 ++++++++++++ src/mcp_audit/proof_cli.py | 148 ++++ src/mcp_audit/proof_models.py | 339 +++++++++ src/mcp_audit/proof_observer.py | 843 +++++++++++++++++++++++ src/mcp_audit/proof_trust.py | 674 ++++++++++++++++++ tests/test_proof_before_action.py | 480 +++++++++++++ 12 files changed, 3205 insertions(+) create mode 100644 docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md create mode 100644 docs/PROOF-BEFORE-ACTION.md create mode 100644 examples/proof-before-action.yaml create mode 100644 src/mcp_audit/proof_capsule.py create mode 100644 src/mcp_audit/proof_cli.py create mode 100644 src/mcp_audit/proof_models.py create mode 100644 src/mcp_audit/proof_observer.py create mode 100644 src/mcp_audit/proof_trust.py create mode 100644 tests/test_proof_before_action.py diff --git a/README.md b/README.md index 5276ec5..1d5c761 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,12 @@ Every MCP server wired into your editor is a process that can read your files, r Read-only by default: it never edits a config and reports env-var **key names only** (never values). Use `--skip-connect` for a zero-touch config-only pass that does not spawn MCP servers or contact remote endpoints; connected scans, package verification, downloads, and LLM analysis make their extra reach explicit in the command. +For pre-run behavioral evidence, MCPAudit also includes +[Proof Before Action](docs/PROOF-BEFORE-ACTION.md): a local-only CLI that runs a +synthetic command in a disposable no-network container, compares observed +effects with a declaration, joins repository MCP dependencies to local +mcp-trust evidence, and exports verifiable JSON plus offline HTML. + > **🌐 Try it in your browser, no install:** paste any MCP client config at **[mcp-audit.saagarpatel.dev](https://mcp-audit.saagarpatel.dev)** for an instant config-only trust report. It runs this exact engine, never launches configured servers, never contacts configured endpoints, and stores nothing. The CLI below adds the connected deep checks (prompt-injection, SSRF, the lethal trifecta, schema drift, SARIF). ## ⚡ 60-second start diff --git a/docs/OUTPUT-CONTRACT.md b/docs/OUTPUT-CONTRACT.md index 1836866..306f9a5 100644 --- a/docs/OUTPUT-CONTRACT.md +++ b/docs/OUTPUT-CONTRACT.md @@ -100,6 +100,37 @@ The generated JSON Schema for the current model is checked in at `examples/schemas/audit-report.schema.json` and is tested against the live Pydantic model. +## Proof Before Action v1 + +Proof Before Action is a separate strict evidence contract; it does not change +`AuditReport` schema version `1`. The five version identifiers are: + +- `proof-before-action.declaration.v1` +- `proof-before-action.observation.v1` +- `proof-before-action.trust-manifest.v1` +- `proof-before-action.capsule.v1` +- `proof-before-action.capsule-index.v1` + +The authoritative JSON Schemas are emitted from the live strict Pydantic models +with `proof-before-action schema CONTRACT`. Unknown fields are rejected. +Optional additive fields may be added within v1. A removal, rename, retype, +requiredness change, evidence-semantics change, or canonicalization change +requires a new contract identifier. + +`capsule.json` is canonical JSON with sorted keys, compact separators, UTF-8, one +terminal newline, and no floating-point values. Its payload hash covers the +declaration, observation, comparison, release trust manifest, producer state, +and limitations. `capsule-index.json` binds hashes and byte lengths for the JSON +evidence and offline HTML view, plus subject and producer commits. Internal +hashes prove consistency only. The verifier reports authority as `anchored` only +when the caller supplies a matching independently recorded root SHA-256. + +`proof-before-action inspect` exits `0` for a passing comparison, `1` for a +blocked or unknown comparison, and `2` when validation or observation cannot +complete. `proof-before-action verify` exits `0` only when every requested hash, +schema, commit, and authority check passes; otherwise it exits `1`. Both commands +write one JSON object to standard output. + ## SafeForge Manifest v0 SafeForge uses a separate, additive evidence-envelope contract; it does not diff --git a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md new file mode 100644 index 0000000..38894a5 --- /dev/null +++ b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md @@ -0,0 +1,79 @@ +# Proof Before Action Threat Model + +## Assets and trust boundaries + +The protected assets are host files and credentials, host databases, network +authority, the reviewed repository, mcp-trust evidence, capsule integrity, and +operator understanding. Untrusted inputs include the command, repository +contents, tool configuration, dependency names, command output, and any existing +capsule. + +The implementation has four relevant boundaries: + +1. the macOS host and local Docker client; +2. the Colima Linux VM; +3. the restricted Docker container; +4. the exported evidence directory. + +The container is disposable. The Colima VM is not treated as a security +boundary equivalent to a fresh mountless VM. + +## Enforced controls + +- The untrusted command executes only in the container. +- Docker image lookup is local-only; the tool never pulls an image. +- The runtime container has network mode `none`, a read-only image root, no + host mounts, no forwarded sockets, no inherited host environment, all Linux + capabilities dropped, `no-new-privileges`, UID/GID `65534:65534`, and CPU, + memory, PID, time, and tmpfs bounds. +- Container configuration is read back and mismatches block execution. +- Known secret-bearing files, detected literal credentials, non-UTF-8/binary + assets, databases not clearly named as synthetic SQLite fixtures, and every + input symlink are rejected before staging. +- Repository and evidence collection have file-count and byte bounds. +- Runtime archive collection rejects path traversal, links, and special files. +- Raw stdout and stderr are omitted. Arguments receive best-effort secret-option + redaction. Output is redirected through a bounded in-container file before + hashing. Config environment/header values are never retained. +- Network effects are tested only with synthetic loopback attempts. The product + has no external-network test mode. +- Offline HTML uses escaped text, no JavaScript, and a restrictive CSP. +- Capsule, artifact, payload, subject-commit, producer-commit, schema, and + optional external-root checks fail closed. + +## Residual threats and honest unknowns + +| Threat or surface | Status | Consequence | +| --- | --- | --- | +| Container, VM, or hypervisor escape | Unknown | Could bypass the container controls. A capsule records containment as `partial`. | +| Current Colima VM host sharing | Not a proven isolation boundary | The VM may expose broader host-adjacent state than the runtime container. A hostile-kernel test should use a fresh mountless VM instead. | +| macOS Keychain, TCC, XPC, Apple Events, GUI, devices, and host kernel | Unobserved | The Linux fixture cannot justify claims about these surfaces. | +| Transient create-delete or write-restore | Unobserved | Final-state hashing can miss an attempt that leaves no persisted delta. | +| Nested or very short-lived child processes | Incompletely observed | The declared top-level executable is bound, but child executable identities and effects can escape complete process attribution. | +| SQLite transactions with no final delta | Unobserved | Semantic comparison proves final content, not every query or transaction attempt. | +| Non-SQLite databases | File-level only | Semantic records and remote database effects are unknown. | +| Network destination | Unobserved | Namespace counters reveal common IP/TCP/UDP attempts, not the requested hostname or endpoint. | +| Loopback inside the namespace | Available | A command can contact its own processes; the evidence marks attempts but does not call loopback external contact. | +| Output links or special files | Fail-closed | Collection stops; the effect is not silently omitted and no completed capsule is issued. | +| Unknown secret formats or low-entropy secret hashes | Residual risk | Redaction is best effort, and a digest can sometimes be guessed. Review declarations and commands before sharing capsules. | +| Malicious local Docker daemon or image | Trusted locally | A local image can contain hostile infrastructure. Pin and independently verify the image digest. | +| Internal capsule hashes | Consistency only | They do not prove who authorized the capsule. Record the index root in an external authority channel. | +| mcp-trust grade applicability | Evidence-limited | Stale, masked, missing, version-unbound, or dirty-source evidence remains unknown. | + +## False claims the product must not make + +A successful run means the persisted regular-file/SQLite state and observable +network counters matched the declaration within this container experiment. It +does not mean the command is safe, cannot mutate, is sandboxed on macOS, is free +of data exfiltration paths, or is approved for release. + +`pass` is a deterministic comparison result. Release authority still belongs to +the operator and must account for every recorded limitation and unknown. + +## Safer high-risk profile + +For deliberately hostile native code or kernel-focused testing, use a freshly +created VM with no host-directory sharing, no host sockets, no credentials, no +external network interface, an immutable input image, and destruction after +evidence extraction. That profile is deliberately outside this finite local +developer tool until it has its own live, repeatable isolation proof. diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md new file mode 100644 index 0000000..cf3c31b --- /dev/null +++ b/docs/PROOF-BEFORE-ACTION.md @@ -0,0 +1,145 @@ +# Proof Before Action + +Proof Before Action answers one bounded question before a developer runs or +releases an agent-backed tool: + +> What did this command actually do inside a disposable boundary, how did that +> differ from its declaration, and what release-trust evidence is still unknown? + +It is a local CLI and portable artifact, not a general sandbox or release +authority. The command under test never runs directly on the host. + +## Workflow + +Create a declaration: + +```yaml +schema_version: proof-before-action.declaration.v1 +name: read local fixture +tools: [node] +permissions: [] +destinations: + files: [] + databases: [] + network: [] +side_effects: + filesystem: none + database: none + network: none +limitations: [] +``` + +Ensure the selected container image already exists locally. Proof Before Action +will not pull it. Then inspect: + +```console +proof-before-action inspect \ + --repo ./repository-under-review \ + --declaration ./proof-before-action.yaml \ + --trust-root ../mcp-trust \ + --output ./proof-capsule \ + -- node -e "require('fs').readFileSync('README.md')" +``` + +`inspect` exits `0` only when actual-vs-declared comparison passes, `1` for a +comparison block or unknown, and `2` when observation or input validation is +blocked. It always uses Docker network mode `none`; there is no option to enable +network access. + +The output directory contains: + +- `capsule.json`: canonical evidence; +- `report.html`: a no-script offline projection; +- `capsule-index.json`: artifact hashes and commit bindings. + +Save the printed root SHA-256 in an authority-controlled record if the capsule +needs provenance stronger than internal consistency. Verify later: + +```console +proof-before-action verify ./proof-capsule \ + --expect-subject-commit "$SUBJECT_COMMIT" \ + --expect-producer-commit "$PRODUCER_COMMIT" \ + --expect-schema proof-before-action.capsule.v1 \ + --expect-root-sha256 "$RECORDED_ROOT" +``` + +Without `--expect-root-sha256`, a successful verification result reports +`authority: unverified`: internal hashes cannot establish who authorized the +artifact. + +## Observation contract + +The observer: + +1. copies a bounded, symlink-free, UTF-8 text snapshot plus explicitly named + synthetic SQLite fixtures into a temporary staging image without `.git`, + dependency caches, build output, known secret files, or detected literal + credentials; +2. creates a non-root container with no host mount, no forwarded socket, + network mode `none`, a read-only image root, all capabilities dropped, + `no-new-privileges`, and bounded CPU, memory, process, and tmpfs resources; +3. runs the command against a disposable tmpfs workspace; +4. collects file hashes, SQLite schema/row digests, and Linux IP/TCP/UDP counter + deltas while the container remains alive; +5. removes the container and temporary staging image. + +File and SQLite comparisons are complete for persisted regular files that can be +collected. `attempted: null` means no attempt could be inferred; it does not mean +the action was proven unable to attempt the effect. Network counters distinguish +an observed attempt from no counter change, but cannot identify the requested +destination. Link or special-file output blocks collection rather than silently +disappearing. Command output is redirected inside the bounded evidence tmpfs +under an OS file-size limit before it is hashed and omitted. + +## Release trust manifest + +Repository-only discovery covers `.mcp.json`, `.vscode/mcp.json`, +`.cursor/mcp.json`, MCP-named `package.json` and `pyproject.toml` dependencies, +and `server.json` packages. Every occurrence gets a stable dependency ID and +exact source pointer. Environment and header values are never copied; only key +names are retained. + +The join uses the local mcp-trust catalog snapshot, catalog seed, +`masked-grades.json`, and spec-shift format version. Missing, stale, masked, +ambiguous, unmatched, dirty-source, or version-unbound evidence remains explicit +in the manifest. A grade is historical evidence about an observed MCP surface, +not an endorsement or runtime-safety proof. + +Freshness is evaluated at the current UTC date, recorded separately from the +snapshot generation timestamp. Runs are byte-stable within that date; evidence +can correctly cross the 90-day stale boundary on a later date. + +## Schemas and compatibility + +The authoritative strict Pydantic models are in `proof_models.py`; unknown fields +are rejected. Machine-readable JSON Schema can be emitted without running an +observation: + +```console +proof-before-action schema declaration +proof-before-action schema observation +proof-before-action schema trust-manifest +proof-before-action schema capsule +proof-before-action schema capsule-index +``` + +All current contract identifiers end in `.v1`. Additive changes require optional +fields. Removing, renaming, retyping, changing requiredness, changing canonical +JSON semantics, or changing evidence meaning requires a new version identifier. +The capsule index is versioned separately so the portable envelope can evolve +without silently changing capsule semantics. + +Canonical JSON uses UTF-8, sorted keys, compact separators, one terminal newline, +and no floating-point values. The primitive is compatible with AIGCCore's +canonical JSON and SHA-256 approach; the source commit is recorded in every +capsule. MCPAudit owns these product-specific schemas and verification rules. + +## Deliberate boundary + +Proof Before Action does not install dependencies, pull images, run connected +MCP scans, contact external services, publish artifacts, modify the reviewed +repository, prove macOS-specific behavior, or claim container-escape resistance. +Binary repository assets and databases that are not clearly named synthetic +SQLite fixtures are rejected rather than copied into the disposable boundary. +Read the [threat model](PROOF-BEFORE-ACTION-THREAT-MODEL.md) before treating a +passing capsule as release evidence. diff --git a/examples/proof-before-action.yaml b/examples/proof-before-action.yaml new file mode 100644 index 0000000..597cd6d --- /dev/null +++ b/examples/proof-before-action.yaml @@ -0,0 +1,15 @@ +schema_version: proof-before-action.declaration.v1 +name: read repository metadata +tools: + - node +permissions: [] +destinations: + files: [] + databases: [] + network: [] +side_effects: + filesystem: none + database: none + network: none +limitations: + - This declaration covers only the synthetic command supplied to the observer. diff --git a/pyproject.toml b/pyproject.toml index c407e0d..73ff1ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ Issues = "https://github.com/saagpatel/MCPAudit/issues" [project.scripts] mcp-audit = "mcp_audit.cli:main" +proof-before-action = "mcp_audit.proof_cli:main" # Registry alias: a console script named == the PyPI package (mcp-audits), so the # MCP server launches via `uvx mcp-audits serve` (the MCP Registry runs a pypi/stdio # server as `uvx `, and uvx resolves to a same-named executable). diff --git a/src/mcp_audit/proof_capsule.py b/src/mcp_audit/proof_capsule.py new file mode 100644 index 0000000..433e826 --- /dev/null +++ b/src/mcp_audit/proof_capsule.py @@ -0,0 +1,444 @@ +"""Actual-vs-declared comparison, deterministic capsule export, and verification.""" + +from __future__ import annotations + +import fnmatch +import html +import json +import os +import subprocess +from pathlib import Path +from typing import Any, Literal + +from mcp_audit import __version__ +from mcp_audit.proof_models import ( + CAPSULE_INDEX_SCHEMA, + CAPSULE_SCHEMA, + ActionDeclaration, + BillComparison, + CapsuleIndex, + CapsuleIntegrity, + CapsulePayload, + ComparisonFinding, + EvidenceCapsule, + IndexedArtifact, + Observation, + ProducerEvidence, + ReleaseTrustManifest, + canonical_json_bytes, + sha256_bytes, +) + +_MAX_INDEX_BYTES = 1024 * 1024 +_MAX_CAPSULE_BYTES = 32 * 1024 * 1024 +_MAX_REPORT_BYTES = 8 * 1024 * 1024 + + +def compare_bill(declaration: ActionDeclaration, observation: Observation) -> BillComparison: + capabilities: list[str] = [] + findings: list[ComparisonFinding] = [] + executable = observation.command.executable + if executable not in declaration.tools: + findings.append( + ComparisonFinding( + code="undeclared_tool", + severity="error", + message=f"observed executable {executable!r} is not declared", + evidence=[executable], + ) + ) + if observation.file_changes: + capabilities.append("file_write") + if declaration.side_effects.filesystem != "write" and "file_write" not in declaration.permissions: + findings.append( + ComparisonFinding( + code="undeclared_file_write", + severity="error", + message="the command changed files without declaring file-write authority", + evidence=[item.path for item in observation.file_changes], + ) + ) + outside = [ + item.path + for item in observation.file_changes + if declaration.destinations.files + and not any(fnmatch.fnmatch(item.path, pattern) for pattern in declaration.destinations.files) + ] + if outside: + findings.append( + ComparisonFinding( + code="expanded_file_destination", + severity="error", + message="observed file changes expanded beyond declared destinations", + evidence=outside, + ) + ) + if observation.database_changes: + capabilities.append("database_write") + if declaration.side_effects.database != "write" and "database_write" not in declaration.permissions: + findings.append( + ComparisonFinding( + code="undeclared_database_write", + severity="error", + message="the command changed a database without declaring database-write authority", + evidence=[item.path for item in observation.database_changes], + ) + ) + outside = [ + item.path + for item in observation.database_changes + if declaration.destinations.databases + and not any(fnmatch.fnmatch(item.path, pattern) for pattern in declaration.destinations.databases) + ] + if outside: + findings.append( + ComparisonFinding( + code="expanded_database_destination", + severity="error", + message="observed database changes expanded beyond declared destinations", + evidence=outside, + ) + ) + if observation.network.surface.attempted: + capabilities.append("network") + if declaration.side_effects.network not in {"attempt", "connect"} and ( + "network" not in declaration.permissions + ): + findings.append( + ComparisonFinding( + code="undeclared_network_attempt", + severity="error", + message="the command attempted network activity without declaring network authority", + evidence=[key for key, value in observation.network.counters.items() if value > 0], + ) + ) + elif declaration.destinations.network: + findings.append( + ComparisonFinding( + code="network_destination_unknown", + severity="unknown", + message="kernel counters observed activity but cannot identify the destination", + ) + ) + if observation.command.timed_out: + findings.append( + ComparisonFinding( + code="command_timeout", + severity="error", + message="the command exceeded the observation time limit", + ) + ) + elif observation.command.exit_code != 0: + findings.append( + ComparisonFinding( + code="command_failed", + severity="error", + message=f"the command exited with status {observation.command.exit_code}", + ) + ) + if ( + not observation.filesystem.complete + or not observation.database.complete + or not observation.network.surface.complete + ): + findings.append( + ComparisonFinding( + code="observation_incomplete", + severity="unknown", + message="one or more requested observation surfaces were incomplete", + ) + ) + verdict: Literal["pass", "block", "unknown"] = ( + "block" + if any(item.severity == "error" for item in findings) + else "unknown" + if any(item.severity == "unknown" for item in findings) + else "pass" + ) + return BillComparison( + declared_tools=sorted(declaration.tools), + observed_tools=[executable], + declared_permissions=sorted(declaration.permissions), + observed_capabilities=sorted(set(capabilities)), + findings=findings, + verdict=verdict, + ) + + +def build_capsule( + declaration: ActionDeclaration, + observation: Observation, + comparison: BillComparison, + trust_manifest: ReleaseTrustManifest, +) -> EvidenceCapsule: + commit, dirty = _producer_git_state() + producer_limitations: list[str] = [] + if commit is None: + producer_limitations.append( + "Producer commit is UNKNOWN; producer authority cannot be bound to source." + ) + if dirty: + producer_limitations.append( + "Producer worktree is dirty; the producer commit does not bind all executing code." + ) + limitations = sorted( + set( + declaration.limitations + + observation.limitations + + trust_manifest.limitations + + producer_limitations + + [ + "Internal hashes prove consistency, not authority; anchor capsule-index.json externally.", + "Containment is partial because container/VM/hypervisor escape resistance is not proven.", + ] + ) + ) + payload = CapsulePayload( + declaration=declaration, + observation=observation, + comparison=comparison, + trust_manifest=trust_manifest, + producer=ProducerEvidence( + version=__version__, + commit=commit, + dirty=dirty, + ), + limitations=limitations, + ) + return EvidenceCapsule( + payload=payload, + integrity=CapsuleIntegrity(payload_sha256=sha256_bytes(canonical_json_bytes(payload))), + ) + + +def export_capsule(capsule: EvidenceCapsule, output: Path) -> str: + if output.is_symlink(): + raise ValueError("output directory must not be a symlink") + if output.exists() and any(output.iterdir()): + raise ValueError("output directory must be absent or empty") + output.mkdir(parents=True, exist_ok=True) + capsule_bytes = canonical_json_bytes(capsule) + html_bytes = render_offline_html(capsule).encode("utf-8") + if len(capsule_bytes) > _MAX_CAPSULE_BYTES or len(html_bytes) > _MAX_REPORT_BYTES: + raise ValueError("capsule or offline report exceeds the verification size limit") + (output / "capsule.json").write_bytes(capsule_bytes) + (output / "report.html").write_bytes(html_bytes) + artifacts = [ + IndexedArtifact( + path="capsule.json", + sha256=sha256_bytes(capsule_bytes), + bytes=len(capsule_bytes), + content_type="application/json", + logical_role="evidence", + ), + IndexedArtifact( + path="report.html", + sha256=sha256_bytes(html_bytes), + bytes=len(html_bytes), + content_type="text/html", + logical_role="view", + ), + ] + index = CapsuleIndex( + subject_commit=capsule.payload.trust_manifest.repository_commit, + producer_commit=capsule.payload.producer.commit, + artifacts=artifacts, + ) + index_bytes = canonical_json_bytes(index) + (output / "capsule-index.json").write_bytes(index_bytes) + return sha256_bytes(index_bytes) + + +def render_offline_html(capsule: EvidenceCapsule) -> str: + comparison = capsule.payload.comparison + trust = capsule.payload.trust_manifest + color = "#4ade80" if comparison.verdict == "pass" else "#fb7185" + findings = ( + "".join( + f"
  • {html.escape(item.code)} {html.escape(item.message)}
  • " + for item in comparison.findings + ) + or "
  • No declaration mismatch was found.
  • " + ) + trust_rows = ( + "".join( + "" + f"{html.escape(entry.dependency.config_name)}" + f"{html.escape(entry.dependency.identity_name or 'unknown')}" + f"{html.escape(entry.evidence.state)}" + f"{html.escape(entry.evidence.grade or 'unknown')}" + "" + for entry in trust.entries + ) + or "No MCP dependency was discovered." + ) + limitations = "".join(f"
  • {html.escape(item)}
  • " for item in capsule.payload.limitations) + return f""" + + + +Proof Before Action + +

    Proof Before Action

    +

    Action: {html.escape(capsule.payload.declaration.name)}

    +

    Actual vs declared: {html.escape(comparison.verdict.upper())}

    +

    Offline projection. The canonical evidence is capsule.json.

    +

    Observed effects

    +
      +
    • File changes: {len(capsule.payload.observation.file_changes)}
    • +
    • Database changes: {len(capsule.payload.observation.database_changes)}
    • +
    • Network attempt observed: {html.escape(str(capsule.payload.observation.network.surface.attempted))}
    • +
    +

    Declaration comparison

      {findings}
    +

    Release trust manifest

    + +{trust_rows}
    ConfigIdentityEvidence stateGrade
    +

    Limitations and unknowns

      {limitations}
    + +""" + + +def verify_capsule( + root: Path, + *, + expect_subject_commit: str | None = None, + expect_producer_commit: str | None = None, + expect_schema: str | None = None, + expect_root_sha256: str | None = None, +) -> dict[str, Any]: + errors: list[dict[str, str]] = [] + required = ("capsule.json", "report.html", "capsule-index.json") + for name in required: + path = root / name + if path.is_symlink(): + errors.append({"code": "unsafe_artifact", "message": f"{name} is a symlink"}) + elif not path.is_file(): + errors.append({"code": "missing_artifact", "message": f"{name} is missing"}) + if errors: + return {"valid": False, "errors": errors} + sizes = { + "capsule-index.json": _MAX_INDEX_BYTES, + "capsule.json": _MAX_CAPSULE_BYTES, + "report.html": _MAX_REPORT_BYTES, + } + for name, maximum in sizes.items(): + if (root / name).stat().st_size > maximum: + errors.append({"code": "artifact_too_large", "message": name}) + if errors: + return {"valid": False, "errors": errors} + index_bytes = (root / "capsule-index.json").read_bytes() + root_sha256 = sha256_bytes(index_bytes) + try: + index = CapsuleIndex.model_validate_json(index_bytes) + except Exception as exc: # Pydantic reports a stable failure class below. + return { + "valid": False, + "root_sha256": root_sha256, + "errors": [{"code": "index_schema_invalid", "message": type(exc).__name__}], + } + if index.schema_version != CAPSULE_INDEX_SCHEMA: + errors.append({"code": "index_schema_unsupported", "message": index.schema_version}) + for artifact in index.artifacts: + path = root / artifact.path + if path.is_symlink(): + errors.append({"code": "unsafe_artifact", "message": artifact.path}) + continue + if not path.is_file(): + errors.append({"code": "missing_artifact", "message": artifact.path}) + continue + value = path.read_bytes() + if len(value) != artifact.bytes or sha256_bytes(value) != artifact.sha256: + errors.append({"code": "artifact_tampered", "message": artifact.path}) + capsule_bytes = (root / "capsule.json").read_bytes() + try: + raw = json.loads(capsule_bytes) + actual_schema = raw.get("schema_version") + if actual_schema != CAPSULE_SCHEMA: + errors.append({"code": "capsule_schema_unsupported", "message": str(actual_schema)}) + capsule = EvidenceCapsule.model_validate(raw) + except Exception as exc: + errors.append({"code": "capsule_schema_invalid", "message": type(exc).__name__}) + capsule = None + if capsule is not None: + payload_digest = sha256_bytes(canonical_json_bytes(capsule.payload)) + if payload_digest != capsule.integrity.payload_sha256: + errors.append({"code": "payload_tampered", "message": "payload hash mismatch"}) + if expect_schema and capsule.schema_version != expect_schema: + errors.append( + { + "code": "expected_schema_mismatch", + "message": f"expected {expect_schema}, got {capsule.schema_version}", + } + ) + subject_commit = capsule.payload.trust_manifest.repository_commit + producer_commit = capsule.payload.producer.commit + if index.subject_commit != subject_commit: + errors.append( + { + "code": "index_subject_mismatch", + "message": "capsule index subject commit does not match the capsule", + } + ) + if index.producer_commit != producer_commit: + errors.append( + { + "code": "index_producer_mismatch", + "message": "capsule index producer commit does not match the capsule", + } + ) + if expect_subject_commit and subject_commit != expect_subject_commit: + errors.append( + { + "code": "subject_commit_mismatch", + "message": f"expected {expect_subject_commit}, got {subject_commit}", + } + ) + if expect_producer_commit and producer_commit != expect_producer_commit: + errors.append( + { + "code": "producer_commit_mismatch", + "message": f"expected {expect_producer_commit}, got {producer_commit}", + } + ) + if expect_root_sha256 and root_sha256 != expect_root_sha256: + errors.append( + { + "code": "authority_root_mismatch", + "message": f"expected {expect_root_sha256}, got {root_sha256}", + } + ) + return { + "valid": not errors, + "root_sha256": root_sha256, + "authority": "anchored" if expect_root_sha256 else "unverified", + "errors": errors, + } + + +def _producer_git_state() -> tuple[str | None, bool | None]: + root = Path(__file__).resolve().parents[2] + try: + commit = subprocess.run( + ["git", "-C", str(root), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + timeout=5, + env={"PATH": os.environ.get("PATH", "")}, + ).stdout.strip() + status = subprocess.run( + ["git", "-C", str(root), "status", "--porcelain"], + check=True, + capture_output=True, + text=True, + timeout=5, + env={"PATH": os.environ.get("PATH", "")}, + ).stdout + return commit, bool(status) + except (OSError, subprocess.SubprocessError): + return None, None diff --git a/src/mcp_audit/proof_cli.py b/src/mcp_audit/proof_cli.py new file mode 100644 index 0000000..e3a51ae --- /dev/null +++ b/src/mcp_audit/proof_cli.py @@ -0,0 +1,148 @@ +"""Standalone Proof Before Action command-line interface.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import click +import yaml # type: ignore[import-untyped] +from pydantic import BaseModel, ValidationError + +from mcp_audit.proof_capsule import ( + build_capsule, + compare_bill, + export_capsule, + verify_capsule, +) +from mcp_audit.proof_models import ( + ActionDeclaration, + CapsuleIndex, + EvidenceCapsule, + Observation, + ReleaseTrustManifest, +) +from mcp_audit.proof_observer import ObservationBlocked, observe_command +from mcp_audit.proof_trust import build_release_trust_manifest + + +@click.group() +def main() -> None: + """Observe first, compare with declared limits, then emit portable evidence.""" + + +@main.command(context_settings={"ignore_unknown_options": True}) +@click.option( + "--repo", + type=click.Path(path_type=Path, exists=True, file_okay=False, readable=True), + required=True, +) +@click.option( + "--declaration", + type=click.Path(path_type=Path, exists=True, dir_okay=False, readable=True), + required=True, +) +@click.option( + "--trust-root", + type=click.Path(path_type=Path, exists=True, file_okay=False, readable=True), +) +@click.option("--image", default="node:24-slim", show_default=True) +@click.option("--timeout", "timeout_seconds", default=45, type=click.IntRange(1, 600)) +@click.option("--output", type=click.Path(path_type=Path), required=True) +@click.argument("command", nargs=-1, type=click.UNPROCESSED, required=True) +def inspect( + repo: Path, + declaration: Path, + trust_root: Path | None, + image: str, + timeout_seconds: int, + output: Path, + command: tuple[str, ...], +) -> None: + """Run COMMAND in the disposable observer and export JSON plus offline HTML.""" + try: + payload = yaml.safe_load(declaration.read_text(encoding="utf-8")) + declared = ActionDeclaration.model_validate(payload) + observed = observe_command( + repo, + list(command), + image=image, + timeout_seconds=timeout_seconds, + ) + comparison = compare_bill(declared, observed) + trust = build_release_trust_manifest(repo, trust_root) + capsule = build_capsule(declared, observed, comparison, trust) + root_sha256 = export_capsule(capsule, output) + except (OSError, ValueError, ValidationError, ObservationBlocked) as exc: + click.echo( + json.dumps( + { + "ok": False, + "error": { + "code": "inspection_blocked", + "message": str(exc).replace(str(Path.home()), "$HOME"), + }, + }, + sort_keys=True, + ) + ) + raise click.exceptions.Exit(2) from None + click.echo( + json.dumps( + { + "ok": comparison.verdict == "pass", + "verdict": comparison.verdict, + "output": str(output).replace(str(Path.home()), "$HOME"), + "root_sha256": root_sha256, + }, + sort_keys=True, + ) + ) + if comparison.verdict != "pass": + raise click.exceptions.Exit(1) + + +@main.command("verify") +@click.argument( + "capsule_root", + type=click.Path(path_type=Path, exists=True, file_okay=False, readable=True), +) +@click.option("--expect-subject-commit") +@click.option("--expect-producer-commit") +@click.option("--expect-schema") +@click.option("--expect-root-sha256") +def verify_command( + capsule_root: Path, + expect_subject_commit: str | None, + expect_producer_commit: str | None, + expect_schema: str | None, + expect_root_sha256: str | None, +) -> None: + """Verify hashes, schemas, commits, and optional external root authority.""" + result = verify_capsule( + capsule_root, + expect_subject_commit=expect_subject_commit, + expect_producer_commit=expect_producer_commit, + expect_schema=expect_schema, + expect_root_sha256=expect_root_sha256, + ) + click.echo(json.dumps(result, sort_keys=True)) + if not result["valid"]: + raise click.exceptions.Exit(1) + + +@main.command("schema") +@click.argument( + "contract", + type=click.Choice(["declaration", "observation", "trust-manifest", "capsule", "capsule-index"]), +) +def schema_command(contract: str) -> None: + """Print one authoritative JSON Schema for local tooling and compatibility checks.""" + models: dict[str, type[BaseModel]] = { + "declaration": ActionDeclaration, + "observation": Observation, + "trust-manifest": ReleaseTrustManifest, + "capsule": EvidenceCapsule, + "capsule-index": CapsuleIndex, + } + click.echo(json.dumps(models[contract].model_json_schema(), sort_keys=True)) diff --git a/src/mcp_audit/proof_models.py b/src/mcp_audit/proof_models.py new file mode 100644 index 0000000..0eb4402 --- /dev/null +++ b/src/mcp_audit/proof_models.py @@ -0,0 +1,339 @@ +"""Versioned contracts for the local-first Proof Before Action product.""" + +from __future__ import annotations + +import hashlib +import json +from enum import StrEnum +from typing import Any, Final, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +DECLARATION_SCHEMA: Final = "proof-before-action.declaration.v1" +OBSERVATION_SCHEMA: Final = "proof-before-action.observation.v1" +TRUST_MANIFEST_SCHEMA: Final = "proof-before-action.trust-manifest.v1" +CAPSULE_SCHEMA: Final = "proof-before-action.capsule.v1" +CAPSULE_INDEX_SCHEMA: Final = "proof-before-action.capsule-index.v1" + + +class StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class EffectIntent(StrEnum): + NONE = "none" + READ = "read" + WRITE = "write" + ATTEMPT = "attempt" + CONNECT = "connect" + + +class DeclaredDestinations(StrictModel): + files: list[str] = Field(default_factory=list) + databases: list[str] = Field(default_factory=list) + network: list[str] = Field(default_factory=list) + + +class DeclaredEffects(StrictModel): + filesystem: EffectIntent = EffectIntent.NONE + database: EffectIntent = EffectIntent.NONE + network: EffectIntent = EffectIntent.NONE + + +class ActionDeclaration(StrictModel): + schema_version: Literal["proof-before-action.declaration.v1"] = DECLARATION_SCHEMA + name: str = Field(min_length=1) + tools: list[str] = Field(min_length=1) + permissions: list[str] = Field(default_factory=list) + destinations: DeclaredDestinations = Field(default_factory=DeclaredDestinations) + side_effects: DeclaredEffects = Field(default_factory=DeclaredEffects) + limitations: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def normalize_unique_fields(self) -> ActionDeclaration: + for field_name in ("tools", "permissions"): + values = getattr(self, field_name) + if len(values) != len(set(values)): + raise ValueError(f"{field_name} must not contain duplicates") + if ( + self.side_effects.filesystem == EffectIntent.WRITE or "file_write" in self.permissions + ) and not self.destinations.files: + raise ValueError("file-write authority requires at least one file destination") + if ( + self.side_effects.database == EffectIntent.WRITE or "database_write" in self.permissions + ) and not self.destinations.databases: + raise ValueError("database-write authority requires at least one database destination") + if ( + self.side_effects.network in {EffectIntent.ATTEMPT, EffectIntent.CONNECT} + or "network" in self.permissions + ) and not self.destinations.network: + raise ValueError("network authority requires at least one network destination") + return self + + +class FileChange(StrictModel): + path: str + change: Literal["added", "modified", "deleted", "type_changed"] + before_sha256: str | None = None + after_sha256: str | None = None + + +class DatabaseChange(StrictModel): + path: str + change: Literal["added", "modified", "deleted", "unreadable"] + before_sha256: str | None = None + after_sha256: str | None = None + changed_tables: list[str] = Field(default_factory=list) + limitations: list[str] = Field(default_factory=list) + + +class SurfaceObservation(StrictModel): + attempted: bool | None + decision: Literal["allowed", "blocked", "unknown", "not_applicable"] + outcome: Literal["succeeded", "failed", "unknown", "not_applicable"] + persisted: Literal["changed", "unchanged", "unknown"] + mechanism: str + complete: bool + limitations: list[str] = Field(default_factory=list) + + +class IsolationEvidence(StrictModel): + provider: Literal["docker-in-colima"] = "docker-in-colima" + image_reference: str + image_id: str + runtime_user: Literal["65534:65534"] + container_network_mode: str + log_driver: Literal["none"] + root_filesystem_read_only: bool + capabilities_dropped: bool + no_new_privileges: bool + pids_limit: Literal[128] + memory_bytes: Literal[536870912] + nano_cpus: Literal[1000000000] + tmpfs_paths: list[str] + host_mounts: list[str] = Field(default_factory=list) + secrets_forwarded: list[str] = Field(default_factory=list) + containment: Literal["partial"] + limitations: list[str] = Field(default_factory=list) + + +class CommandEvidence(StrictModel): + argv: list[str] + argv_sha256: str + executable: str + exit_code: int | None + timed_out: bool + stdout_sha256: str + stderr_sha256: str + stdout_bytes: int + stderr_bytes: int + + +class NetworkEvidence(StrictModel): + surface: SurfaceObservation + counters: dict[str, int] = Field(default_factory=dict) + external_contact_count: Literal[0] = 0 + + +class Observation(StrictModel): + schema_version: Literal["proof-before-action.observation.v1"] = OBSERVATION_SCHEMA + isolation: IsolationEvidence + command: CommandEvidence + filesystem: SurfaceObservation + file_changes: list[FileChange] = Field(default_factory=list) + database: SurfaceObservation + database_changes: list[DatabaseChange] = Field(default_factory=list) + network: NetworkEvidence + limitations: list[str] = Field(default_factory=list) + + +class ComparisonFinding(StrictModel): + code: str + severity: Literal["error", "unknown", "info"] + message: str + evidence: list[str] = Field(default_factory=list) + + +class BillComparison(StrictModel): + declared_tools: list[str] + observed_tools: list[str] + declared_permissions: list[str] + observed_capabilities: list[str] + findings: list[ComparisonFinding] = Field(default_factory=list) + verdict: Literal["pass", "block", "unknown"] + + +class DependencyOccurrence(StrictModel): + dependency_id: str + source_path: str + source_pointer: str + config_name: str + transport: str + identity_kind: Literal["npm", "pypi", "remote", "git", "binary", "unknown"] + identity_name: str | None = None + requested_version: str | None = None + version_source: Literal["config_exact", "unresolved", "not_applicable"] + command_basename: str | None = None + args_sha256: str + env_key_names: list[str] = Field(default_factory=list) + header_key_names: list[str] = Field(default_factory=list) + + +class DiscoveryDiagnostic(StrictModel): + source_path: str + source_pointer: str + code: str + message: str + + +class TrustEvidence(StrictModel): + state: Literal[ + "current", + "stale", + "masked", + "unmatched", + "unverifiable", + "ambiguous", + ] + match_state: Literal["exact", "name_only", "ambiguous", "unmatched"] + slug: str | None = None + grade: str | None = None + transparency: str | None = None + scanned_at: str | None = None + engine: str | None = None + engine_version: str | None = None + scan_mode: str | None = None + network_isolation: Literal["verified_none", "unknown", "not_applicable"] = "unknown" + version_alignment: Literal[ + "exact", + "dependency_unresolved", + "evidence_unversioned", + "not_applicable", + "unknown", + ] = "unknown" + unknown_reasons: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def masked_records_withhold_details(self) -> TrustEvidence: + if self.state == "masked" and any( + value is not None for value in (self.grade, self.transparency, self.scanned_at, self.engine) + ): + raise ValueError("masked trust evidence must not expose withheld scan details") + return self + + +class TrustEntry(StrictModel): + dependency: DependencyOccurrence + evidence: TrustEvidence + + +class TrustSource(StrictModel): + kind: Literal["mcp-trust-local"] = "mcp-trust-local" + repository_commit: str | None + dirty: bool | None + schema_versions: dict[str, int | str] + file_sha256: dict[str, str] + snapshot_generated_at: str + evaluated_at: str + + +class ReleaseTrustManifest(StrictModel): + schema_version: Literal["proof-before-action.trust-manifest.v1"] = TRUST_MANIFEST_SCHEMA + repository_commit: str | None + repository_dirty: bool | None + discovery_coverage: Literal["complete", "partial", "unknown"] + dependencies: list[DependencyOccurrence] + diagnostics: list[DiscoveryDiagnostic] = Field(default_factory=list) + trust_source: TrustSource | None = None + entries: list[TrustEntry] + limitations: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def every_dependency_has_one_entry(self) -> ReleaseTrustManifest: + dependency_ids = [item.dependency_id for item in self.dependencies] + entry_ids = [item.dependency.dependency_id for item in self.entries] + if sorted(dependency_ids) != sorted(entry_ids): + raise ValueError("every dependency occurrence must have exactly one trust entry") + return self + + +class ProducerEvidence(StrictModel): + name: Literal["mcp-audits"] = "mcp-audits" + version: str + commit: str | None + dirty: bool | None + aigccore_primitive_source_commit: str = "d8c570cf148bb502b7ed0cc7fd58f1e054697180" + + +class CapsulePayload(StrictModel): + declaration: ActionDeclaration + observation: Observation + comparison: BillComparison + trust_manifest: ReleaseTrustManifest + producer: ProducerEvidence + limitations: list[str] = Field(default_factory=list) + + +class CapsuleIntegrity(StrictModel): + algorithm: Literal["sha256"] = "sha256" + payload_sha256: str + + +class EvidenceCapsule(StrictModel): + schema_version: Literal["proof-before-action.capsule.v1"] = CAPSULE_SCHEMA + payload: CapsulePayload + integrity: CapsuleIntegrity + + +class IndexedArtifact(StrictModel): + path: str + sha256: str + bytes: int + content_type: str + logical_role: Literal["evidence", "view"] + + +class CapsuleIndex(StrictModel): + schema_version: Literal["proof-before-action.capsule-index.v1"] = CAPSULE_INDEX_SCHEMA + capsule_schema_version: Literal["proof-before-action.capsule.v1"] = CAPSULE_SCHEMA + subject_commit: str | None + producer_commit: str | None + artifacts: list[IndexedArtifact] + + @model_validator(mode="after") + def artifact_set_is_fixed(self) -> CapsuleIndex: + by_path = {item.path: item for item in self.artifacts} + if sorted(by_path) != ["capsule.json", "report.html"] or len(by_path) != len(self.artifacts): + raise ValueError("capsule index must contain exactly capsule.json and report.html") + if ( + by_path["capsule.json"].content_type != "application/json" + or by_path["capsule.json"].logical_role != "evidence" + or by_path["report.html"].content_type != "text/html" + or by_path["report.html"].logical_role != "view" + ): + raise ValueError("capsule artifact roles and content types are fixed") + return self + + +def canonical_json_bytes(value: BaseModel | dict[str, Any] | list[Any]) -> bytes: + """AIGCCore-compatible compact, sorted JSON for this integer-only contract.""" + payload: Any = value.model_dump(mode="json") if isinstance(value, BaseModel) else value + _reject_floats(payload) + return ( + json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + b"\n" + ) + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _reject_floats(value: Any) -> None: + if isinstance(value, float): + raise ValueError("canonical Proof Before Action JSON forbids floating-point numbers") + if isinstance(value, dict): + for nested in value.values(): + _reject_floats(nested) + elif isinstance(value, list): + for nested in value: + _reject_floats(nested) diff --git a/src/mcp_audit/proof_observer.py b/src/mcp_audit/proof_observer.py new file mode 100644 index 0000000..38c04da --- /dev/null +++ b/src/mcp_audit/proof_observer.py @@ -0,0 +1,843 @@ +"""Disposable command observation with no host mounts or forwarded secrets.""" + +from __future__ import annotations + +import hashlib +import io +import json +import os +import re +import secrets +import shutil +import sqlite3 +import stat +import subprocess +import tarfile +import tempfile +import time +from pathlib import Path, PurePosixPath +from typing import Any, Literal, cast + +from mcp_audit.proof_models import ( + CommandEvidence, + DatabaseChange, + FileChange, + IsolationEvidence, + NetworkEvidence, + Observation, + SurfaceObservation, + canonical_json_bytes, + sha256_bytes, +) + +_IGNORED_NAMES = { + ".git", + ".venv", + "node_modules", + "__pycache__", + ".pytest_cache", + ".ruff_cache", + "dist", + "build", +} +_SENSITIVE_INPUT_NAMES = { + ".env", + ".netrc", + ".npmrc", + ".pypirc", + "credentials.json", + "id_dsa", + "id_ecdsa", + "id_ed25519", + "id_rsa", +} +_SENSITIVE_ARGUMENT = re.compile( + r"(?i)(?:^|[-_])(api[-_]?key|auth|authorization|cookie|credential|password|" + r"private[-_]?key|secret|token)(?:$|[-_=])" +) +_SENSITIVE_VALUE = re.compile( + r"(?i)(?:authorization|proxy-authorization|cookie|set-cookie)\s*[:=]|" + r"\bbearer\s+[A-Za-z0-9._~+/=-]+|" + r"://[^/@\s]+:[^/@\s]+@|" + r"\b(?:AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,})\b" +) +_SENSITIVE_KEY = re.compile( + r"(?i)(?:^|[_-])(?:api[_-]?key|auth|authorization|cookie|credential|password|" + r"private[_-]?key|secret|token)(?:$|[_-])" +) +_TEXT_SECRET_ASSIGNMENT = re.compile( + r"(?im)^\s*[A-Za-z0-9._-]*(?:api[_-]?key|auth|authorization|cookie|credential|" + r"password|private[_-]?key|secret|token)[A-Za-z0-9._-]*\s*[:=]\s*" + r"[\"']?([^\"'#\r\n]+)" +) +_SAFE_DATABASE_NAME = re.compile(r"(?i)(?:fixture|sample|seed|synthetic|test)") +_PLACEHOLDER_VALUE = re.compile( + r"(?i)^(?:\$\{?[A-Z0-9_]+\}?|<[^>]+>|changeme|dummy|example|fixture|" + r"placeholder|redacted|sample|synthetic|test)$" +) +_DATABASE_SUFFIXES = {".db", ".sqlite", ".sqlite3"} +_REPO_CONFIG_NAMES = {".mcp.json", "server.json"} +_MAX_FILES = 10_000 +_MAX_INPUT_BYTES = 512 * 1024 * 1024 +_MAX_OUTPUT_BYTES = 256 * 1024 +_MAX_TEXT_FILE_BYTES = 16 * 1024 * 1024 +_WRAPPER = r""" +set -eu +cp -R /pba-input/. /workspace/ +cat /proc/net/snmp > /pba/network.before +ulimit -f 512 +set +e +"$@" > /pba/stdout 2> /pba/stderr +rc=$? +set -e +cat /proc/net/snmp > /pba/network.after +printf '%s\n' "$rc" > /pba/exit-code +touch /pba/complete +sleep 600 +""" + + +class ObservationBlocked(RuntimeError): + """The command was not run because the disposable boundary could not be proven.""" + + +def observe_command( + repo: Path, + command: list[str], + *, + image: str, + timeout_seconds: int = 45, +) -> Observation: + if not command: + raise ObservationBlocked("a command is required after --") + if timeout_seconds < 1 or timeout_seconds > 600: + raise ObservationBlocked("timeout must be between 1 and 600 seconds") + root = Path(tempfile.mkdtemp(prefix="proof-before-action-", dir="/private/tmp")) + staged = root / "staged" + collected = root / "collected" + evidence = root / "evidence" + staged.mkdir(mode=0o700) + collected.mkdir(mode=0o700) + evidence.mkdir(mode=0o700) + container_id: str | None = None + staging_container_id: str | None = None + runtime_image: str | None = None + try: + _stage_repository(repo.resolve(), staged) + before_files = _file_snapshot(staged) + before_databases = _database_snapshot(staged) + _make_disposable_writable(staged) + image_id = _local_image_id(image) + _require_image_tools(image) + name = "pba-" + secrets.token_hex(8) + runtime_image = name + "-input" + stage_create = _run( + [ + "docker", + "create", + "--name", + name + "-stage", + "--network", + "none", + "--entrypoint", + "/bin/true", + image, + ], + timeout=20, + ) + if stage_create.returncode != 0: + raise ObservationBlocked( + "input staging container creation failed: " + _safe_error(stage_create.stderr) + ) + staging_container_id = stage_create.stdout.decode().strip() + copied = _run( + ["docker", "cp", str(staged) + "/.", f"{staging_container_id}:/pba-input"], + timeout=60, + ) + if copied.returncode != 0: + raise ObservationBlocked("staged input copy failed: " + _safe_error(copied.stderr)) + committed = _run( + ["docker", "commit", staging_container_id, runtime_image], + timeout=60, + ) + if committed.returncode != 0: + raise ObservationBlocked( + "content-addressed staging image failed: " + _safe_error(committed.stderr) + ) + _run(["docker", "rm", "-f", staging_container_id], timeout=20) + staging_container_id = None + create = _run( + [ + "docker", + "create", + "--name", + name, + "--network", + "none", + "--read-only", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--pids-limit", + "128", + "--memory", + "512m", + "--cpus", + "1", + "--log-driver", + "none", + "--tmpfs", + "/tmp:rw,noexec,nosuid,nodev,size=67108864,mode=1777", + "--tmpfs", + "/workspace:rw,nosuid,nodev,size=536870912,mode=0777", + "--tmpfs", + "/pba:rw,noexec,nosuid,nodev,size=8388608,mode=0777", + "--workdir", + "/workspace", + "--user", + "65534:65534", + "--env", + "HOME=/nonexistent", + "--env", + "LANG=C.UTF-8", + "--entrypoint", + "/bin/sh", + runtime_image, + "-c", + _WRAPPER, + "proof-before-action-wrapper", + *command, + ], + timeout=20, + ) + if create.returncode != 0: + raise ObservationBlocked("container creation failed: " + _safe_error(create.stderr)) + container_id = create.stdout.decode().strip() + inspect = _inspect_container(container_id) + isolation = _isolation_evidence(image, image_id, inspect) + started = _run(["docker", "start", container_id], timeout=20) + if started.returncode != 0: + raise ObservationBlocked("container start failed: " + _safe_error(started.stderr)) + + timed_out = not _wait_for_completion(container_id, timeout_seconds) + if timed_out: + _run(["docker", "kill", container_id], timeout=10) + if not timed_out: + _collect_tree(container_id, "/workspace", collected, timeout=60) + _collect_tree(container_id, "/pba", evidence, timeout=20) + _run(["docker", "kill", container_id], timeout=10) + exit_code = _read_exit_code(evidence / "exit-code") + if timed_out: + exit_code = None + + after_files = before_files if timed_out else _file_snapshot(collected) + after_databases = before_databases if timed_out else _database_snapshot(collected) + file_changes = _diff_files(before_files, after_files) + database_changes = _diff_databases(before_databases, after_databases) + network = _network_evidence( + evidence / "network.before", + evidence / "network.after", + timed_out=timed_out, + ) + stdout = _read_bounded(evidence / "stdout") + stderr = _read_bounded(evidence / "stderr") + filesystem = SurfaceObservation( + attempted=True if file_changes else None, + decision="allowed" if file_changes else "unknown", + outcome="succeeded" if file_changes else "unknown", + persisted="changed" if file_changes else "unchanged", + mechanism="complete before/after hash inventory of the disposable workspace", + complete=True, + limitations=[ + "Transient create-delete or write-restore attempts are not observable " + "without syscall tracing." + ], + ) + database = SurfaceObservation( + attempted=True if database_changes else None, + decision="allowed" if database_changes else "unknown", + outcome="succeeded" if database_changes else "unknown", + persisted="changed" if database_changes else "unchanged", + mechanism="SQLite schema, row-count, and row-digest comparison plus file hashes", + complete=not any(change.change == "unreadable" for change in database_changes), + limitations=[ + "Only copied SQLite files receive semantic inspection; other databases " + "remain file-level evidence.", + "Transient transactions that leave no SQLite or journal delta are not observable.", + ], + ) + return Observation( + isolation=isolation, + command=CommandEvidence( + argv=_redact_argv(command), + argv_sha256=sha256_bytes(canonical_json_bytes(command)), + executable=Path(command[0]).name, + exit_code=exit_code, + timed_out=timed_out, + stdout_sha256=sha256_bytes(stdout), + stderr_sha256=sha256_bytes(stderr), + stdout_bytes=len(stdout), + stderr_bytes=len(stderr), + ), + filesystem=filesystem, + file_changes=file_changes, + database=database, + database_changes=database_changes, + network=network, + limitations=[ + "The Linux guest cannot represent macOS Keychain, TCC, XPC, Apple Events, " + "GUI, device, or kernel effects.", + "The container boundary is defense in depth, not proof against container, " + "VM, or hypervisor escape.", + "Raw command output is hashed and omitted to reduce credential and private-payload exposure.", + "Command arguments use best-effort credential redaction; argv and output " + "hashes can still reveal low-entropy secrets by guessing.", + "Link or special-file output stops collection rather than being silently omitted.", + "Nested child-process identities and short-lived process effects are not completely traced.", + ], + ) + finally: + if container_id: + _run(["docker", "rm", "-f", container_id], timeout=20) + if staging_container_id: + _run(["docker", "rm", "-f", staging_container_id], timeout=20) + if runtime_image: + _run(["docker", "image", "rm", "-f", runtime_image], timeout=30) + shutil.rmtree(root, ignore_errors=True) + + +def _stage_repository(source: Path, destination: Path) -> None: + if not source.is_dir(): + raise ObservationBlocked("repository path is not a directory") + file_count = 0 + total_bytes = 0 + for path in sorted(source.rglob("*")): + relative = path.relative_to(source) + if any(part in _IGNORED_NAMES for part in relative.parts): + continue + if path.is_symlink(): + raise ObservationBlocked(f"input contains a symlink: {relative.as_posix()}") + target = destination / relative + if path.is_dir(): + target.mkdir(parents=True, exist_ok=True) + continue + if not path.is_file(): + raise ObservationBlocked(f"unsupported input file type: {relative.as_posix()}") + if path.name.lower() in _SENSITIVE_INPUT_NAMES: + raise ObservationBlocked( + f"repository contains a sensitive file that will not be copied: {relative.as_posix()}" + ) + file_count += 1 + total_bytes += path.stat().st_size + if file_count > _MAX_FILES or total_bytes > _MAX_INPUT_BYTES: + raise ObservationBlocked("repository exceeds the staging file-count or byte limit") + _validate_staged_input(path, relative) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(path, target) + + +def _make_disposable_writable(root: Path) -> None: + for path in root.rglob("*"): + os.chmod(path, 0o777 if path.is_dir() else 0o666) + os.chmod(root, 0o777) + + +def _redact_argv(argv: list[str]) -> list[str]: + redacted: list[str] = [] + redact_next = False + for value in argv: + if redact_next: + redacted.append("") + redact_next = False + continue + if "=" in value and _SENSITIVE_ARGUMENT.search(value.split("=", 1)[0]): + redacted.append(value.split("=", 1)[0] + "=") + continue + if _SENSITIVE_VALUE.search(value): + redacted.append("") + continue + redacted.append(value) + if _SENSITIVE_ARGUMENT.search(value): + redact_next = True + return redacted + + +def _validate_staged_input(path: Path, relative: Path) -> None: + if path.suffix.lower() in _DATABASE_SUFFIXES: + with path.open("rb") as database: + header = database.read(16) + if not _SAFE_DATABASE_NAME.search(path.name) or header != b"SQLite format 3\0": + raise ObservationBlocked( + "database input must be an explicitly named synthetic SQLite fixture: " + relative.as_posix() + ) + return + if path.stat().st_size > _MAX_TEXT_FILE_BYTES: + raise ObservationBlocked(f"binary or oversized text input will not be copied: {relative.as_posix()}") + value = path.read_bytes() + if b"\0" in value: + raise ObservationBlocked(f"binary or oversized text input will not be copied: {relative.as_posix()}") + try: + text = value.decode("utf-8") + except UnicodeDecodeError as exc: + raise ObservationBlocked(f"non-UTF-8 input will not be copied: {relative.as_posix()}") from exc + if "-----BEGIN" in text and "PRIVATE KEY-----" in text: + raise ObservationBlocked( + f"repository input appears to contain private key material: {relative.as_posix()}" + ) + if _SENSITIVE_VALUE.search(text): + raise ObservationBlocked( + f"repository input appears to contain credential material: {relative.as_posix()}" + ) + if path.suffix.lower() == ".json" or path.name in _REPO_CONFIG_NAMES: + try: + payload = json.loads(text) + except json.JSONDecodeError: + payload = None + if payload is not None and _json_contains_literal_secret(payload): + raise ObservationBlocked(f"repository JSON contains a literal credential: {relative.as_posix()}") + for match in _TEXT_SECRET_ASSIGNMENT.finditer(text): + if not _is_placeholder(match.group(1)): + raise ObservationBlocked( + f"repository text contains a literal credential assignment: {relative.as_posix()}" + ) + + +def _json_contains_literal_secret(value: Any) -> bool: + if isinstance(value, dict): + for key, nested in value.items(): + key_text = str(key) + if key_text in {"env", "headers"} and isinstance(nested, dict): + if any(_literal_secret_value(item) for item in nested.values()): + return True + if _SENSITIVE_KEY.search(key_text) and _literal_secret_value(nested): + return True + if key_text == "args" and isinstance(nested, list): + args = [str(item) for item in nested] + if _redact_argv(args) != args: + return True + if _json_contains_literal_secret(nested): + return True + elif isinstance(value, list): + return any(_json_contains_literal_secret(item) for item in value) + return False + + +def _literal_secret_value(value: Any) -> bool: + if isinstance(value, str): + return bool(value.strip()) and not _is_placeholder(value) + return value is not None + + +def _is_placeholder(value: str) -> bool: + normalized = value.strip().strip("\"'") + return bool(_PLACEHOLDER_VALUE.fullmatch(normalized)) + + +def _local_image_id(image: str) -> str: + result = _run(["docker", "image", "inspect", "--format", "{{.Id}}", image], timeout=20) + if result.returncode != 0: + raise ObservationBlocked( + "the image must already exist locally; Proof Before Action never pulls code or images" + ) + return result.stdout.decode().strip() + + +def _require_image_tools(image: str) -> None: + result = _run( + [ + "docker", + "run", + "--rm", + "--network", + "none", + "--read-only", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--entrypoint", + "/bin/sh", + image, + "-c", + "test -r /proc/net/snmp && command -v tar >/dev/null", + ], + timeout=20, + ) + if result.returncode != 0: + raise ObservationBlocked("local image lacks the required sh, tar, or procfs observer") + + +def _wait_for_completion(container_id: str, timeout_seconds: int) -> bool: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + marker = _run( + ["docker", "exec", container_id, "test", "-f", "/pba/complete"], + timeout=5, + ) + if marker.returncode == 0: + return True + state = _run( + ["docker", "inspect", "--format", "{{.State.Running}}", container_id], + timeout=5, + ) + if state.returncode != 0 or state.stdout.decode().strip() != "true": + logs = _run(["docker", "logs", container_id], timeout=10) + raise ObservationBlocked( + "observer wrapper exited before evidence collection: " + + _safe_error(logs.stderr or logs.stdout) + ) + time.sleep(0.1) + return False + + +def _collect_tree(container_id: str, source: str, destination: Path, *, timeout: int) -> None: + archived = _run( + ["docker", "exec", container_id, "tar", "-C", source, "-cf", "-", "."], + timeout=timeout, + ) + if archived.returncode != 0: + raise ObservationBlocked("runtime evidence collection failed: " + _safe_error(archived.stderr)) + file_count = 0 + total_bytes = 0 + try: + with tarfile.open(fileobj=io.BytesIO(archived.stdout), mode="r:") as archive: + for member in archive: + relative = PurePosixPath(member.name) + parts = tuple(part for part in relative.parts if part != ".") + if relative.is_absolute() or ".." in parts: + raise ObservationBlocked("runtime evidence archive contains an unsafe path") + if not parts: + continue + target = destination.joinpath(*parts) + if member.isdir(): + target.mkdir(parents=True, exist_ok=True) + elif member.isfile(): + file_count += 1 + total_bytes += member.size + if file_count > _MAX_FILES or total_bytes > _MAX_INPUT_BYTES: + raise ObservationBlocked("runtime evidence exceeds the file-count or byte limit") + source_file = archive.extractfile(member) + if source_file is None: + raise ObservationBlocked("runtime evidence file could not be read") + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("wb") as output: + shutil.copyfileobj(source_file, output) + else: + raise ObservationBlocked( + "runtime evidence contains a link or special file; collection stopped" + ) + except tarfile.TarError as exc: + raise ObservationBlocked("runtime evidence archive is invalid") from exc + + +def _inspect_container(container_id: str) -> dict[str, Any]: + result = _run(["docker", "inspect", container_id], timeout=20) + if result.returncode != 0: + raise ObservationBlocked("container isolation readback failed") + payload = json.loads(result.stdout) + return cast(dict[str, Any], payload[0]) + + +def _isolation_evidence(image: str, image_id: str, inspect: dict[str, Any]) -> IsolationEvidence: + host = inspect.get("HostConfig", {}) + mounts = inspect.get("Mounts", []) + network = str(host.get("NetworkMode", "unknown")) + cap_drop = {str(item).upper() for item in host.get("CapDrop", [])} + security = [str(item) for item in host.get("SecurityOpt", [])] + root_read_only = bool(host.get("ReadonlyRootfs")) + runtime_user = str(inspect.get("Config", {}).get("User", "")) + no_new_privileges = any("no-new-privileges" in item for item in security) + log_driver = str(host.get("LogConfig", {}).get("Type", "")) + pids_limit = host.get("PidsLimit") + memory_bytes = host.get("Memory") + nano_cpus = host.get("NanoCpus") + tmpfs_paths = sorted(str(item) for item in host.get("Tmpfs", {})) + if ( + network != "none" + or "ALL" not in cap_drop + or not root_read_only + or mounts + or runtime_user != "65534:65534" + or not no_new_privileges + or log_driver != "none" + or pids_limit != 128 + or memory_bytes != 536870912 + or nano_cpus != 1000000000 + or tmpfs_paths != ["/pba", "/tmp", "/workspace"] + ): + raise ObservationBlocked( + "container isolation readback did not match the required fail-closed profile" + ) + return IsolationEvidence( + image_reference=image, + image_id=image_id, + runtime_user="65534:65534", + container_network_mode=network, + log_driver="none", + root_filesystem_read_only=root_read_only, + capabilities_dropped=True, + no_new_privileges=no_new_privileges, + pids_limit=128, + memory_bytes=536870912, + nano_cpus=1000000000, + tmpfs_paths=tmpfs_paths, + host_mounts=[], + secrets_forwarded=[], + containment="partial", + limitations=[ + "The container has no host mounts or forwarded sockets, but it runs inside " + "a networked Colima VM.", + "A container or VM escape could reach a broader host-adjacent surface; " + "hostile-kernel isolation is not proven.", + "Loopback remains available inside the isolated network namespace.", + ], + ) + + +def _file_snapshot(root: Path) -> dict[str, tuple[str, str | None]]: + snapshot: dict[str, tuple[str, str | None]] = {} + for path in sorted(root.rglob("*")): + relative = path.relative_to(root).as_posix() + mode = path.lstat().st_mode + if stat.S_ISLNK(mode): + snapshot[relative] = ("symlink", None) + elif stat.S_ISDIR(mode): + snapshot[relative] = ("directory", None) + elif stat.S_ISREG(mode): + snapshot[relative] = ("file", _sha256_file(path)) + else: + snapshot[relative] = ("other", None) + return snapshot + + +def _diff_files( + before: dict[str, tuple[str, str | None]], + after: dict[str, tuple[str, str | None]], +) -> list[FileChange]: + changes: list[FileChange] = [] + for path in sorted(set(before) | set(after)): + old = before.get(path) + new = after.get(path) + if old == new: + continue + change: Literal["added", "modified", "deleted", "type_changed"] + if old is None: + change = "added" + elif new is None: + change = "deleted" + elif old[0] != new[0]: + change = "type_changed" + else: + change = "modified" + changes.append( + FileChange( + path=path, + change=change, + before_sha256=old[1] if old else None, + after_sha256=new[1] if new else None, + ) + ) + return changes + + +def _database_snapshot(root: Path) -> dict[str, dict[str, Any]]: + values: dict[str, dict[str, Any]] = {} + for path in sorted(root.rglob("*")): + if not path.is_file() or path.suffix.lower() not in _DATABASE_SUFFIXES: + continue + relative = path.relative_to(root).as_posix() + try: + values[relative] = _sqlite_semantic_snapshot(path) + except (OSError, sqlite3.Error, ValueError) as exc: + values[relative] = { + "file_sha256": _sha256_file(path), + "error": type(exc).__name__, + } + return values + + +def _sqlite_semantic_snapshot(path: Path) -> dict[str, Any]: + connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True) + try: + connection.execute("PRAGMA query_only=ON") + tables = [ + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + ) + ] + table_values: dict[str, dict[str, Any]] = {} + for table in tables: + quoted = '"' + table.replace('"', '""') + '"' + count = int(connection.execute(f"SELECT count(*) FROM {quoted}").fetchone()[0]) + if count > 10_000: + raise ValueError("SQLite table exceeds the semantic row cap") + rows = connection.execute(f"SELECT * FROM {quoted} ORDER BY rowid").fetchall() + normalized = [[_sqlite_value(item) for item in row] for row in rows] + table_values[table] = { + "rows": count, + "sha256": sha256_bytes(canonical_json_bytes(normalized)), + } + schema = [ + list(row) + for row in connection.execute( + "SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name" + ) + ] + return { + "file_sha256": _sha256_file(path), + "schema_sha256": sha256_bytes(canonical_json_bytes(schema)), + "tables": table_values, + } + finally: + connection.close() + + +def _sqlite_value(value: Any) -> Any: + if value is None or isinstance(value, (str, int)): + return value + if isinstance(value, float): + return {"float_text": repr(value)} + if isinstance(value, bytes): + return {"bytes_sha256": hashlib.sha256(value).hexdigest(), "bytes": len(value)} + return {"value_sha256": hashlib.sha256(repr(value).encode()).hexdigest()} + + +def _diff_databases( + before: dict[str, dict[str, Any]], after: dict[str, dict[str, Any]] +) -> list[DatabaseChange]: + changes: list[DatabaseChange] = [] + for path in sorted(set(before) | set(after)): + old = before.get(path) + new = after.get(path) + if old == new: + continue + change: Literal["added", "modified", "deleted", "unreadable"] + if old is None: + change = "added" + elif new is None: + change = "deleted" + elif "error" in old or "error" in new: + change = "unreadable" + else: + change = "modified" + old_tables = old.get("tables", {}) if old else {} + new_tables = new.get("tables", {}) if new else {} + changed_tables = sorted( + table + for table in set(old_tables) | set(new_tables) + if old_tables.get(table) != new_tables.get(table) + ) + changes.append( + DatabaseChange( + path=path, + change=change, + before_sha256=old.get("file_sha256") if old else None, + after_sha256=new.get("file_sha256") if new else None, + changed_tables=changed_tables, + limitations=["SQLite semantic inspection failed."] if change == "unreadable" else [], + ) + ) + return changes + + +def _network_evidence(before: Path, after: Path, *, timed_out: bool) -> NetworkEvidence: + if timed_out or not before.is_file() or not after.is_file(): + return NetworkEvidence( + surface=SurfaceObservation( + attempted=None, + decision="unknown", + outcome="unknown", + persisted="unknown", + mechanism="Linux network namespace counters", + complete=False, + limitations=["Network counters were unavailable because the run did not exit normally."], + ) + ) + old = _parse_snmp(before) + new = _parse_snmp(after) + keys = ( + ("Tcp", "ActiveOpens"), + ("Tcp", "PassiveOpens"), + ("Tcp", "AttemptFails"), + ("Udp", "OutDatagrams"), + ("Ip", "OutRequests"), + ) + deltas = { + f"{protocol}.{field}": max( + 0, new.get(protocol, {}).get(field, 0) - old.get(protocol, {}).get(field, 0) + ) + for protocol, field in keys + } + attempted = any(value > 0 for value in deltas.values()) + failed = deltas["Tcp.AttemptFails"] > 0 + return NetworkEvidence( + surface=SurfaceObservation( + attempted=attempted, + decision="blocked" if failed else "unknown" if attempted else "not_applicable", + outcome="failed" if failed else "unknown" if attempted else "not_applicable", + persisted="unchanged", + mechanism="per-container /proc/net/snmp counter delta under Docker network mode none", + complete=True, + limitations=[ + "Counters identify common IP/TCP/UDP activity but not the requested " + "destination or every socket family.", + "Docker network mode none proves no ordinary external interface, not " + "resistance to container escape.", + ], + ), + counters=deltas, + ) + + +def _parse_snmp(path: Path) -> dict[str, dict[str, int]]: + lines = path.read_text(encoding="utf-8").splitlines() + result: dict[str, dict[str, int]] = {} + for index in range(0, len(lines) - 1, 2): + headers = lines[index].split() + values = lines[index + 1].split() + if not headers or not values or headers[0] != values[0]: + continue + result[headers[0].rstrip(":")] = { + key: int(value) for key, value in zip(headers[1:], values[1:], strict=False) + } + return result + + +def _read_exit_code(path: Path) -> int | None: + try: + return int(path.read_text(encoding="utf-8").strip()) + except (OSError, ValueError): + return None + + +def _read_bounded(path: Path) -> bytes: + try: + with path.open("rb") as value: + return value.read(_MAX_OUTPUT_BYTES) + except OSError: + return b"" + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as value: + while chunk := value.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def _run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[bytes]: + return subprocess.run( + argv, + check=False, + capture_output=True, + timeout=timeout, + env={"PATH": os.environ.get("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")}, + ) + + +def _safe_error(value: bytes) -> str: + text = value[:4096].decode("utf-8", errors="replace") + return text.replace(str(Path.home()), "$HOME") diff --git a/src/mcp_audit/proof_trust.py b/src/mcp_audit/proof_trust.py new file mode 100644 index 0000000..4394518 --- /dev/null +++ b/src/mcp_audit/proof_trust.py @@ -0,0 +1,674 @@ +"""Repository-only MCP discovery and local mcp-trust evidence joining.""" + +from __future__ import annotations + +import hashlib +import ipaddress +import json +import re +import subprocess +import tomllib +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Literal +from urllib.parse import urlsplit, urlunsplit + +from mcp_audit.proof_models import ( + DependencyOccurrence, + DiscoveryDiagnostic, + ReleaseTrustManifest, + TrustEntry, + TrustEvidence, + TrustSource, + canonical_json_bytes, + sha256_bytes, +) + +_REPO_CONFIGS = (".mcp.json", ".vscode/mcp.json", ".cursor/mcp.json") +_EXACT_VERSION = re.compile(r"^\d+(?:\.\d+)*(?:[-+][0-9A-Za-z.-]+)?$") +_PYPI_NORMALIZE = re.compile(r"[-_.]+") + + +def build_release_trust_manifest(repo: Path, trust_root: Path | None) -> ReleaseTrustManifest: + root = repo.resolve() + dependencies, diagnostics = discover_repo_mcp(root) + commit, dirty = _git_state(root) + if trust_root is None: + entries = [ + TrustEntry( + dependency=item, + evidence=TrustEvidence( + state="unmatched", + match_state="unmatched", + unknown_reasons=["mcp-trust source was not provided"], + ), + ) + for item in dependencies + ] + return ReleaseTrustManifest( + repository_commit=commit, + repository_dirty=dirty, + discovery_coverage="partial" if diagnostics else "complete", + dependencies=dependencies, + diagnostics=diagnostics, + trust_source=None, + entries=entries, + limitations=[ + "Trust evidence is UNKNOWN because no local mcp-trust source was provided.", + *_repository_limitations(commit, dirty), + ], + ) + return _join_trust( + root, + trust_root.resolve(), + dependencies, + diagnostics, + repository_commit=commit, + repository_dirty=dirty, + ) + + +def discover_repo_mcp( + repo: Path, +) -> tuple[list[DependencyOccurrence], list[DiscoveryDiagnostic]]: + dependencies: list[DependencyOccurrence] = [] + diagnostics: list[DiscoveryDiagnostic] = [] + for relative in _REPO_CONFIGS: + path = repo / relative + if not path.is_file(): + continue + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + diagnostics.append( + DiscoveryDiagnostic( + source_path=relative, + source_pointer="/", + code="invalid_config", + message=f"configuration could not be parsed: {type(exc).__name__}", + ) + ) + continue + servers = payload.get("mcpServers", payload.get("servers")) if isinstance(payload, dict) else None + if not isinstance(servers, dict): + diagnostics.append( + DiscoveryDiagnostic( + source_path=relative, + source_pointer="/", + code="missing_server_map", + message="expected an object at mcpServers or servers", + ) + ) + continue + for name, config in servers.items(): + pointer = f"/mcpServers/{_json_pointer(str(name))}" + if not isinstance(config, dict): + diagnostics.append( + DiscoveryDiagnostic( + source_path=relative, + source_pointer=pointer, + code="invalid_entry", + message="server entry must be an object", + ) + ) + continue + dependencies.append(_config_occurrence(relative, pointer, str(name), config)) + + package_json = repo / "package.json" + if package_json.is_file(): + try: + package_payload = json.loads(package_json.read_text(encoding="utf-8")) + for section in ("dependencies", "devDependencies", "optionalDependencies"): + values = package_payload.get(section, {}) + if not isinstance(values, dict): + continue + for name, version in values.items(): + if "mcp" not in str(name).lower(): + continue + dependencies.append( + _package_occurrence( + "package.json", + f"/{section}/{_json_pointer(str(name))}", + str(name), + str(version), + "npm", + ) + ) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + diagnostics.append( + DiscoveryDiagnostic( + source_path="package.json", + source_pointer="/", + code="invalid_manifest", + message=f"package manifest could not be parsed: {type(exc).__name__}", + ) + ) + + pyproject = repo / "pyproject.toml" + if pyproject.is_file(): + try: + project = tomllib.loads(pyproject.read_text(encoding="utf-8")).get("project", {}) + for index, spec in enumerate(project.get("dependencies", [])): + if "mcp" not in str(spec).lower(): + continue + name, version, exact = _parse_pypi_spec(str(spec)) + dependencies.append( + _occurrence( + source_path="pyproject.toml", + source_pointer=f"/project/dependencies/{index}", + config_name=name, + transport="package", + identity_kind="pypi", + identity_name=name, + requested_version=version, + exact=exact, + command_basename=None, + args=[], + ) + ) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: + diagnostics.append( + DiscoveryDiagnostic( + source_path="pyproject.toml", + source_pointer="/project/dependencies", + code="invalid_manifest", + message=f"Python manifest could not be parsed: {type(exc).__name__}", + ) + ) + + descriptor = repo / "server.json" + if descriptor.is_file(): + try: + payload = json.loads(descriptor.read_text(encoding="utf-8")) + packages = payload.get("packages", []) if isinstance(payload, dict) else [] + for index, package in enumerate(packages): + if not isinstance(package, dict): + diagnostics.append( + DiscoveryDiagnostic( + source_path="server.json", + source_pointer=f"/packages/{index}", + code="invalid_entry", + message="package descriptor must be an object", + ) + ) + continue + registry = str(package.get("registryType", "unknown")) + kind = "npm" if registry == "npm" else "pypi" if registry == "pypi" else "unknown" + name = str(package.get("identifier", "")) + version = str(package.get("version", "")) or None + dependencies.append( + _occurrence( + source_path="server.json", + source_pointer=f"/packages/{index}", + config_name=str(payload.get("name", name)), + transport=str(package.get("transport", {}).get("type", "unknown")), + identity_kind=kind, + identity_name=_normalize_package(name, kind), + requested_version=version, + exact=bool(version and _EXACT_VERSION.fullmatch(version)), + command_basename=str(package.get("runtimeHint", "")) or None, + args=[], + ) + ) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + diagnostics.append( + DiscoveryDiagnostic( + source_path="server.json", + source_pointer="/", + code="invalid_manifest", + message=f"MCP registry descriptor could not be parsed: {type(exc).__name__}", + ) + ) + + dependencies.sort(key=lambda item: (item.source_path, item.source_pointer, item.dependency_id)) + diagnostics.sort(key=lambda item: (item.source_path, item.source_pointer, item.code)) + return dependencies, diagnostics + + +def _config_occurrence( + source_path: str, pointer: str, name: str, config: dict[str, Any] +) -> DependencyOccurrence: + command = config.get("command") + args = [str(item) for item in config.get("args", [])] if isinstance(config.get("args", []), list) else [] + url = config.get("url") + kind = "unknown" + identity: str | None = None + version: str | None = None + exact = False + if isinstance(url, str): + kind = "remote" + identity = _normalize_remote(url) + exact = True + elif isinstance(command, str): + basename = Path(command).name.lower() + if basename in {"npx", "npm", "pnpm", "yarn"}: + kind, identity, version, exact = _parse_npm_args(args) + elif basename in {"uvx", "uv"}: + kind, identity, version, exact = _parse_pypi_args(args) + elif basename in {"git"}: + kind = "git" + else: + kind = "binary" + identity = basename + exact = True + env_keys = sorted(str(key) for key in config.get("env", {}) if isinstance(key, str)) + headers = config.get("headers", {}) + header_keys = sorted(str(key) for key in headers if isinstance(headers, dict)) + return _occurrence( + source_path=source_path, + source_pointer=pointer, + config_name=name, + transport="remote" if isinstance(url, str) else "stdio", + identity_kind=kind, + identity_name=identity, + requested_version=version, + exact=exact, + command_basename=Path(command).name if isinstance(command, str) else None, + args=args, + env_key_names=env_keys, + header_key_names=header_keys, + ) + + +def _package_occurrence( + source_path: str, pointer: str, name: str, spec: str, kind: str +) -> DependencyOccurrence: + normalized = _normalize_package(name, kind) + version = spec if _EXACT_VERSION.fullmatch(spec) else None + return _occurrence( + source_path=source_path, + source_pointer=pointer, + config_name=name, + transport="package", + identity_kind=kind, + identity_name=normalized, + requested_version=version, + exact=version is not None, + command_basename=None, + args=[], + ) + + +def _occurrence( + *, + source_path: str, + source_pointer: str, + config_name: str, + transport: str, + identity_kind: str, + identity_name: str | None, + requested_version: str | None, + exact: bool, + command_basename: str | None, + args: list[str], + env_key_names: list[str] | None = None, + header_key_names: list[str] | None = None, +) -> DependencyOccurrence: + material = f"{source_path}\0{source_pointer}\0{identity_kind}\0{identity_name or ''}".encode() + dependency_id = "dep_" + hashlib.sha256(material).hexdigest()[:20] + return DependencyOccurrence( + dependency_id=dependency_id, + source_path=source_path, + source_pointer=source_pointer, + config_name=config_name, + transport=transport, + identity_kind=identity_kind, # type: ignore[arg-type] + identity_name=identity_name, + requested_version=requested_version, + version_source="config_exact" + if exact and requested_version + else ("not_applicable" if exact else "unresolved"), + command_basename=command_basename, + args_sha256=sha256_bytes(canonical_json_bytes(args)), + env_key_names=env_key_names or [], + header_key_names=header_key_names or [], + ) + + +def _join_trust( + repo: Path, + trust_root: Path, + dependencies: list[DependencyOccurrence], + diagnostics: list[DiscoveryDiagnostic], + *, + repository_commit: str | None, + repository_dirty: bool | None, +) -> ReleaseTrustManifest: + files = { + "catalog_snapshot.json": trust_root / "src/mcp_trust/catalog_snapshot.json", + "seed_servers.json": trust_root / "src/mcp_trust/catalog/seed_servers.json", + "masked-grades.json": trust_root / "masked-grades.json", + "spec_shift_verdicts.json": trust_root / "src/mcp_trust/core/spec_shift_verdicts.json", + } + missing = [name for name, path in files.items() if not path.is_file()] + if missing: + diagnostics = [ + *diagnostics, + DiscoveryDiagnostic( + source_path="", + source_pointer="/", + code="trust_source_incomplete", + message=f"missing required trust inputs: {', '.join(sorted(missing))}", + ), + ] + return _unknown_trust_manifest( + dependencies, + diagnostics, + repository_commit, + repository_dirty, + f"mcp-trust source is incomplete: {', '.join(sorted(missing))}", + ) + try: + snapshot = json.loads(files["catalog_snapshot.json"].read_text(encoding="utf-8")) + seed = json.loads(files["seed_servers.json"].read_text(encoding="utf-8")) + masked = set(json.loads(files["masked-grades.json"].read_text(encoding="utf-8"))) + spec_shift = json.loads(files["spec_shift_verdicts.json"].read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + return _unknown_trust_manifest( + dependencies, + diagnostics, + repository_commit, + repository_dirty, + f"mcp-trust source could not be parsed: {type(exc).__name__}", + ) + trust_commit, trust_dirty = _git_state(trust_root) + snapshot_generated_at = str(snapshot.get("generated_at", "")) or "unknown" + evaluated_at = datetime.now(UTC).date().isoformat() + "T00:00:00+00:00" + source = TrustSource( + repository_commit=trust_commit, + dirty=trust_dirty, + schema_versions={ + "catalog_snapshot": snapshot.get("schema_version", "unknown"), + "spec_shift": spec_shift.get("format_version", "unknown"), + }, + file_sha256={name: sha256_bytes(path.read_bytes()) for name, path in sorted(files.items())}, + snapshot_generated_at=snapshot_generated_at, + evaluated_at=evaluated_at, + ) + seed_rows = seed if isinstance(seed, list) else seed.get("servers", []) + records = snapshot.get("servers", []) + entries = [ + TrustEntry( + dependency=dependency, + evidence=_match_dependency( + dependency, + seed_rows if isinstance(seed_rows, list) else [], + records if isinstance(records, list) else [], + masked, + evaluated_at, + ), + ) + for dependency in dependencies + ] + limitations = [ + "mcp-trust grades describe an observed MCP surface, not runtime safety or endorsement.", + "Version applicability is UNKNOWN when mcp-trust evidence is not bound to the " + "exact dependency version.", + "Freshness is evaluated at the recorded current UTC date; the snapshot generation " + "timestamp remains separately bound.", + *_repository_limitations(repository_commit, repository_dirty), + ] + if trust_dirty: + limitations.append("The mcp-trust source worktree is dirty; trust-source authority is UNKNOWN.") + return ReleaseTrustManifest( + repository_commit=repository_commit, + repository_dirty=repository_dirty, + discovery_coverage="partial" if diagnostics else "complete", + dependencies=dependencies, + diagnostics=diagnostics, + trust_source=source, + entries=entries, + limitations=limitations, + ) + + +def _match_dependency( + dependency: DependencyOccurrence, + seed: list[dict[str, Any]], + records: list[dict[str, Any]], + masked: set[str], + evaluated_at: str, +) -> TrustEvidence: + if dependency.identity_name is None: + return TrustEvidence( + state="unmatched", + match_state="unmatched", + unknown_reasons=["dependency identity could not be normalized"], + ) + candidates = [item for item in seed if _source_key(item.get("source", {})) == _dependency_key(dependency)] + if len(candidates) > 1: + return TrustEvidence( + state="ambiguous", + match_state="ambiguous", + unknown_reasons=["multiple mcp-trust catalog identities matched"], + ) + if not candidates: + return TrustEvidence( + state="unmatched", + match_state="unmatched", + unknown_reasons=["no mcp-trust catalog identity matched"], + ) + slug = str(candidates[0].get("slug", "")) + if slug in masked: + return TrustEvidence( + state="masked", + match_state="exact", + slug=slug, + version_alignment="unknown", + unknown_reasons=["operator-masked evidence is intentionally withheld"], + ) + matches = [item for item in records if item.get("slug") == slug] + if len(matches) != 1: + return TrustEvidence( + state="unverifiable", + match_state="exact", + slug=slug, + unknown_reasons=["grade-bearing snapshot record is missing or ambiguous"], + ) + record = matches[0] + stale = _is_stale(record.get("scanned_at"), evaluated_at) + version_alignment: Literal[ + "exact", + "dependency_unresolved", + "evidence_unversioned", + "not_applicable", + "unknown", + ] = ( + "dependency_unresolved" + if dependency.version_source == "unresolved" + else "evidence_unversioned" + if dependency.requested_version + else "not_applicable" + ) + unknowns: list[str] = [] + state = "stale" if stale is True else "current" + if stale is None: + state = "unverifiable" + unknowns.append("scan freshness could not be verified") + if version_alignment in {"dependency_unresolved", "evidence_unversioned"}: + state = "unverifiable" if state == "current" else state + unknowns.append("evidence is not bound to an exact dependency version") + sandbox = record.get("sandbox", {}) + network: Literal["verified_none", "unknown", "not_applicable"] = ( + "verified_none" + if record.get("scan_mode") == "mcpaudit-local-network-off" + and isinstance(sandbox, dict) + and sandbox.get("network") == "none" + else "not_applicable" + if isinstance(sandbox, dict) and sandbox.get("mode") == "not_applicable" + else "unknown" + ) + if network == "unknown": + unknowns.append("mcp-trust record does not prove network isolation") + return TrustEvidence( + state=state, # type: ignore[arg-type] + match_state="exact", + slug=slug, + grade=str(record.get("grade")) if record.get("grade") is not None else None, + transparency=record.get("transparency"), + scanned_at=record.get("scanned_at"), + engine=record.get("engine"), + engine_version=record.get("engine_version"), + scan_mode=record.get("scan_mode"), + network_isolation=network, + version_alignment=version_alignment, + unknown_reasons=unknowns, + ) + + +def _unknown_trust_manifest( + dependencies: list[DependencyOccurrence], + diagnostics: list[DiscoveryDiagnostic], + repository_commit: str | None, + repository_dirty: bool | None, + reason: str, +) -> ReleaseTrustManifest: + return ReleaseTrustManifest( + repository_commit=repository_commit, + repository_dirty=repository_dirty, + discovery_coverage="unknown", + dependencies=dependencies, + diagnostics=diagnostics, + trust_source=None, + entries=[ + TrustEntry( + dependency=item, + evidence=TrustEvidence( + state="unverifiable", + match_state="unmatched", + unknown_reasons=[reason], + ), + ) + for item in dependencies + ], + limitations=[reason, *_repository_limitations(repository_commit, repository_dirty)], + ) + + +def _source_key(source: dict[str, Any]) -> tuple[str, str] | None: + kind = str(source.get("kind", "")) + reference = source.get("reference") + if not isinstance(reference, str): + return None + if kind == "npm": + return "npm", _normalize_package(reference, "npm") + if kind == "pypi": + return "pypi", _normalize_package(reference, "pypi") + if kind == "remote": + return "remote", _normalize_remote(reference) + return kind, reference + + +def _dependency_key(dependency: DependencyOccurrence) -> tuple[str, str]: + return dependency.identity_kind, dependency.identity_name or "" + + +def _parse_npm_args(args: list[str]) -> tuple[str, str | None, str | None, bool]: + candidates = [item for item in args if item and not item.startswith("-")] + if not candidates: + return "npm", None, None, False + raw = candidates[0] + name, version = raw, None + split_at = raw.rfind("@") + if split_at > 0: + name, candidate = raw[:split_at], raw[split_at + 1 :] + if candidate: + version = candidate + exact = bool(version and _EXACT_VERSION.fullmatch(version)) + return "npm", _normalize_package(name, "npm"), version if exact else None, exact + + +def _parse_pypi_args(args: list[str]) -> tuple[str, str | None, str | None, bool]: + candidates = [item for item in args if item and not item.startswith("-") and item not in {"run", "tool"}] + if not candidates: + return "pypi", None, None, False + name, version, exact = _parse_pypi_spec(candidates[0]) + return "pypi", name, version, exact + + +def _parse_pypi_spec(spec: str) -> tuple[str, str | None, bool]: + base = spec.split("[", 1)[0] + if "==" in base: + name, version = base.split("==", 1) + exact = "*" not in version and bool(_EXACT_VERSION.fullmatch(version)) + return _normalize_package(name.strip(), "pypi"), version if exact else None, exact + name = re.split(r"[<>=!~ ]", base, maxsplit=1)[0] + return _normalize_package(name.strip(), "pypi"), None, False + + +def _normalize_package(name: str, kind: str) -> str: + value = name.strip().lower() + if kind == "pypi": + return _PYPI_NORMALIZE.sub("-", value) + return value + + +def _normalize_remote(url: str) -> str: + try: + parsed = urlsplit(url) + except ValueError: + return "sha256:" + hashlib.sha256(url.encode()).hexdigest() + host = parsed.hostname or "" + try: + private = ipaddress.ip_address(host).is_private + except ValueError: + private = host in {"localhost"} or host.endswith(".local") + if private: + return "sha256:" + hashlib.sha256(url.encode()).hexdigest() + netloc = host.lower() + if parsed.port: + netloc += f":{parsed.port}" + return urlunsplit((parsed.scheme.lower(), netloc, parsed.path.rstrip("/"), "", "")) + + +def _is_stale(scanned_at: Any, evaluated_at: str) -> bool | None: + if not isinstance(scanned_at, str) or not evaluated_at: + return None + try: + scanned = datetime.fromisoformat(scanned_at.replace("Z", "+00:00")) + evaluated = datetime.fromisoformat(evaluated_at.replace("Z", "+00:00")) + except ValueError: + return None + if scanned.tzinfo is None: + scanned = scanned.replace(tzinfo=UTC) + if evaluated.tzinfo is None: + evaluated = evaluated.replace(tzinfo=UTC) + if scanned > evaluated: + return None + return (evaluated - scanned).days > 90 + + +def _git_state(root: Path) -> tuple[str | None, bool | None]: + try: + commit = subprocess.run( + ["git", "-C", str(root), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + timeout=5, + ).stdout.strip() + status = subprocess.run( + ["git", "-C", str(root), "status", "--porcelain"], + check=True, + capture_output=True, + text=True, + timeout=5, + ).stdout + return commit, bool(status) + except (OSError, subprocess.SubprocessError): + return None, None + + +def _repository_limitations(commit: str | None, dirty: bool | None) -> list[str]: + limitations: list[str] = [] + if commit is None: + limitations.append("Subject repository commit is UNKNOWN; release evidence is not commit-bound.") + if dirty: + limitations.append( + "Subject repository is dirty; its commit does not bind the inspected working tree." + ) + return limitations + + +def _json_pointer(value: str) -> str: + return value.replace("~", "~0").replace("/", "~1") diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py new file mode 100644 index 0000000..c385fd0 --- /dev/null +++ b/tests/test_proof_before_action.py @@ -0,0 +1,480 @@ +"""End-to-end acceptance coverage for Proof Before Action.""" + +from __future__ import annotations + +import json +import shutil +import sqlite3 +import subprocess +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from mcp_audit.proof_capsule import ( + build_capsule, + compare_bill, + export_capsule, + verify_capsule, +) +from mcp_audit.proof_cli import main +from mcp_audit.proof_models import ( + CAPSULE_SCHEMA, + ActionDeclaration, + canonical_json_bytes, +) +from mcp_audit.proof_observer import ObservationBlocked, _redact_argv, observe_command +from mcp_audit.proof_trust import build_release_trust_manifest + +DOCKER_READY = ( + shutil.which("docker") is not None + and subprocess.run( + ["docker", "image", "inspect", "node:24-slim"], + check=False, + capture_output=True, + ).returncode + == 0 +) +requires_docker = pytest.mark.skipif( + not DOCKER_READY, reason="local node:24-slim image and Docker are required" +) + + +def _declaration(**updates: object) -> ActionDeclaration: + payload: dict[str, object] = { + "schema_version": "proof-before-action.declaration.v1", + "name": "fixture", + "tools": ["node"], + "permissions": [], + "destinations": {"files": [], "databases": [], "network": []}, + "side_effects": {"filesystem": "none", "database": "none", "network": "none"}, + "limitations": [], + } + payload.update(updates) + return ActionDeclaration.model_validate(payload) + + +def _repo(tmp_path: Path) -> Path: + root = tmp_path / "repo" + root.mkdir() + (root / "input.txt").write_text("stable\n", encoding="utf-8") + return root + + +def _empty_trust(repo: Path): + return build_release_trust_manifest(repo, None) + + +@requires_docker +def test_read_only_command_passes_and_is_deterministic(tmp_path: Path) -> None: + repo = _repo(tmp_path) + command = ["node", "-e", "require('fs').readFileSync('input.txt')"] + first = observe_command(repo, command, image="node:24-slim") + second = observe_command(repo, command, image="node:24-slim") + assert first.file_changes == [] + assert first.database_changes == [] + assert first.network.surface.attempted is False + first_comparison = compare_bill(_declaration(), first) + second_comparison = compare_bill(_declaration(), second) + assert first_comparison.verdict == "pass" + first_capsule = build_capsule(_declaration(), first, first_comparison, _empty_trust(repo)) + second_capsule = build_capsule(_declaration(), second, second_comparison, _empty_trust(repo)) + assert canonical_json_bytes(first_capsule) == canonical_json_bytes(second_capsule) + + +@requires_docker +def test_undeclared_file_write_is_detected_and_blocked(tmp_path: Path) -> None: + repo = _repo(tmp_path) + observation = observe_command( + repo, + ["node", "-e", "require('fs').writeFileSync('created.txt','proof')"], + image="node:24-slim", + ) + assert [(item.path, item.change) for item in observation.file_changes] == [("created.txt", "added")] + comparison = compare_bill(_declaration(), observation) + assert comparison.verdict == "block" + assert "undeclared_file_write" in {item.code for item in comparison.findings} + declared_write = _declaration( + destinations={"files": ["created.txt"], "databases": [], "network": []}, + side_effects={"filesystem": "write", "database": "none", "network": "none"}, + ) + assert compare_bill(declared_write, observation).verdict == "pass" + + +@requires_docker +def test_seeded_sqlite_mutation_is_semantically_detected(tmp_path: Path) -> None: + repo = _repo(tmp_path) + database = sqlite3.connect(repo / "seeded.db") + database.execute("CREATE TABLE items(id INTEGER PRIMARY KEY, value TEXT NOT NULL)") + database.execute("INSERT INTO items(value) VALUES ('before')") + database.commit() + database.close() + code = ( + "const {DatabaseSync}=require('node:sqlite');" + "const db=new DatabaseSync('seeded.db');" + "db.exec(\"UPDATE items SET value='after' WHERE id=1\");db.close()" + ) + observation = observe_command(repo, ["node", "-e", code], image="node:24-slim") + assert len(observation.database_changes) == 1 + change = observation.database_changes[0] + assert change.path == "seeded.db" + assert change.change == "modified" + assert change.changed_tables == ["items"] + comparison = compare_bill(_declaration(), observation) + assert "undeclared_database_write" in {item.code for item in comparison.findings} + + +@requires_docker +def test_loopback_network_attempt_is_detected_without_external_contact(tmp_path: Path) -> None: + repo = _repo(tmp_path) + code = ( + "const net=require('net');const s=net.connect(9,'127.0.0.1');" + "s.on('error',()=>process.exit(0));setTimeout(()=>process.exit(0),500)" + ) + observation = observe_command(repo, ["node", "-e", code], image="node:24-slim") + assert observation.network.surface.attempted is True + assert observation.network.external_contact_count == 0 + assert observation.network.counters["Tcp.ActiveOpens"] >= 1 + comparison = compare_bill(_declaration(), observation) + assert "undeclared_network_attempt" in {item.code for item in comparison.findings} + declared_attempt = _declaration( + destinations={"files": [], "databases": [], "network": ["127.0.0.1:9"]}, + side_effects={"filesystem": "none", "database": "none", "network": "attempt"}, + ) + declared_comparison = compare_bill(declared_attempt, observation) + assert declared_comparison.verdict == "unknown" + assert "network_destination_unknown" in {item.code for item in declared_comparison.findings} + + +def test_declaration_omission_is_deterministic() -> None: + from mcp_audit.proof_models import ( + CommandEvidence, + FileChange, + IsolationEvidence, + NetworkEvidence, + Observation, + SurfaceObservation, + ) + + unchanged = SurfaceObservation( + attempted=None, + decision="unknown", + outcome="unknown", + persisted="unchanged", + mechanism="fixture", + complete=True, + ) + observation = Observation( + isolation=IsolationEvidence( + image_reference="fixture", + image_id="sha256:" + "a" * 64, + runtime_user="65534:65534", + container_network_mode="none", + log_driver="none", + root_filesystem_read_only=True, + capabilities_dropped=True, + no_new_privileges=True, + pids_limit=128, + memory_bytes=536870912, + nano_cpus=1000000000, + tmpfs_paths=["/pba", "/tmp", "/workspace"], + containment="partial", + ), + command=CommandEvidence( + argv=["node"], + argv_sha256="c" * 64, + executable="node", + exit_code=0, + timed_out=False, + stdout_sha256="a" * 64, + stderr_sha256="a" * 64, + stdout_bytes=0, + stderr_bytes=0, + ), + filesystem=unchanged.model_copy( + update={"attempted": True, "persisted": "changed", "decision": "allowed"} + ), + file_changes=[FileChange(path="x", change="added", after_sha256="b" * 64)], + database=unchanged, + network=NetworkEvidence(surface=unchanged), + ) + first = compare_bill(_declaration(), observation) + second = compare_bill(_declaration(), observation) + assert first.verdict == "block" + assert canonical_json_bytes(first) == canonical_json_bytes(second) + + +def _trust_fixture(tmp_path: Path) -> Path: + trust = tmp_path / "mcp-trust" + (trust / "src/mcp_trust/catalog").mkdir(parents=True) + (trust / "src/mcp_trust/core").mkdir(parents=True) + seed = [ + { + "slug": "known", + "name": "Known", + "source": {"kind": "npm", "reference": "@fixture/known-mcp"}, + }, + { + "slug": "masked", + "name": "Masked", + "source": {"kind": "npm", "reference": "@fixture/masked-mcp"}, + }, + ] + snapshot = { + "schema_version": 2, + "generated_at": "2026-07-18T00:00:00+00:00", + "servers": [ + { + "slug": "known", + "grade": "B", + "transparency": "high", + "scanned_at": "2026-07-01T00:00:00+00:00", + "engine": "mcpaudit", + "engine_version": "2.4.0", + "scan_mode": "mcpaudit-local-network-off", + "sandbox": {"mode": "docker", "network": "none"}, + } + ], + } + (trust / "src/mcp_trust/catalog/seed_servers.json").write_text(json.dumps(seed), encoding="utf-8") + (trust / "src/mcp_trust/catalog_snapshot.json").write_text(json.dumps(snapshot), encoding="utf-8") + (trust / "masked-grades.json").write_text('["masked"]', encoding="utf-8") + (trust / "src/mcp_trust/core/spec_shift_verdicts.json").write_text( + '{"format_version":2,"servers":{}}', encoding="utf-8" + ) + return trust + + +def test_known_unmatched_and_masked_dependencies_are_all_preserved(tmp_path: Path) -> None: + repo = _repo(tmp_path) + (repo / ".mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "known": { + "command": "npx", + "args": ["@fixture/known-mcp@1.2.3"], + }, + "missing": { + "command": "npx", + "args": ["@fixture/unmatched-mcp@1.0.0"], + }, + "masked": { + "command": "npx", + "args": ["@fixture/masked-mcp@1.0.0"], + }, + } + } + ), + encoding="utf-8", + ) + manifest = build_release_trust_manifest(repo, _trust_fixture(tmp_path)) + assert len(manifest.dependencies) == len(manifest.entries) == 3 + by_name = {entry.dependency.config_name: entry for entry in manifest.entries} + assert by_name["known"].evidence.match_state == "exact" + assert by_name["known"].evidence.version_alignment == "evidence_unversioned" + assert by_name["missing"].evidence.state == "unmatched" + assert by_name["masked"].evidence.state == "masked" + assert by_name["masked"].evidence.grade is None + + +def test_stale_trust_evidence_is_historical_not_current(tmp_path: Path) -> None: + repo = _repo(tmp_path) + (repo / ".mcp.json").write_text( + '{"mcpServers":{"known":{"command":"npx","args":["@fixture/known-mcp"]}}}', + encoding="utf-8", + ) + trust = _trust_fixture(tmp_path) + snapshot_path = trust / "src/mcp_trust/catalog_snapshot.json" + snapshot = json.loads(snapshot_path.read_text()) + snapshot["servers"][0]["scanned_at"] = "2025-01-01T00:00:00+00:00" + snapshot["generated_at"] = "2025-01-02T00:00:00+00:00" + snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8") + manifest = build_release_trust_manifest(repo, trust) + assert manifest.entries[0].evidence.state == "stale" + assert manifest.entries[0].evidence.grade == "B" + assert manifest.trust_source is not None + assert manifest.trust_source.snapshot_generated_at == "2025-01-02T00:00:00+00:00" + assert manifest.trust_source.evaluated_at != manifest.trust_source.snapshot_generated_at + + +@requires_docker +def test_tampering_and_wrong_commit_or_schema_are_detected(tmp_path: Path) -> None: + repo = _repo(tmp_path) + declaration = _declaration() + observation = observe_command(repo, ["node", "-e", "process.exit(0)"], image="node:24-slim") + comparison = compare_bill(declaration, observation) + capsule = build_capsule(declaration, observation, comparison, build_release_trust_manifest(repo, None)) + output = tmp_path / "capsule" + root_sha = export_capsule(capsule, output) + assert verify_capsule(output, expect_root_sha256=root_sha)["valid"] is True + wrong = verify_capsule( + output, + expect_subject_commit="0" * 40, + expect_producer_commit="1" * 40, + expect_schema="proof-before-action.capsule.v999", + ) + codes = {item["code"] for item in wrong["errors"]} + assert { + "subject_commit_mismatch", + "producer_commit_mismatch", + "expected_schema_mismatch", + } <= codes + original_index = (output / "capsule-index.json").read_bytes() + index = json.loads(original_index) + index["subject_commit"] = "0" * 40 + (output / "capsule-index.json").write_bytes(canonical_json_bytes(index)) + semantic_tamper = verify_capsule(output) + assert "index_subject_mismatch" in {item["code"] for item in semantic_tamper["errors"]} + (output / "capsule-index.json").write_bytes(original_index) + payload = bytearray((output / "capsule.json").read_bytes()) + payload[len(payload) // 2] ^= 1 + (output / "capsule.json").write_bytes(payload) + tampered = verify_capsule(output) + assert tampered["valid"] is False + assert "artifact_tampered" in {item["code"] for item in tampered["errors"]} + + +@requires_docker +def test_offline_html_escapes_untrusted_text(tmp_path: Path) -> None: + repo = _repo(tmp_path) + declaration = _declaration(name="") + observation = observe_command(repo, ["node", "-e", "process.exit(0)"], image="node:24-slim") + comparison = compare_bill(declaration, observation) + capsule = build_capsule(declaration, observation, comparison, build_release_trust_manifest(repo, None)) + output = tmp_path / "capsule" + export_capsule(capsule, output) + page = (output / "report.html").read_text(encoding="utf-8") + assert " None: + repo = _repo(tmp_path) + (repo / ".env").write_text("API_TOKEN=do-not-copy\n", encoding="utf-8") + with pytest.raises(ObservationBlocked, match="sensitive file"): + observe_command( + repo, + ["node", "-e", "process.exit(0)"], + image="node:24-slim", + ) + + +@pytest.mark.parametrize( + "secret_config", + [ + '{"mcpServers":{"unsafe":{"command":"node","env":{"OPENAI_API_KEY":"sk-proj-literal"}}}}', + '{"mcpServers":{"unsafe":{"url":"https://example.test","headers":{"X-API-Key":"literal"}}}}', + "OPENAI_API_KEY: literal-value", + ], +) +def test_literal_config_secret_and_sensitive_argv_are_redacted_or_blocked( + tmp_path: Path, secret_config: str +) -> None: + repo = _repo(tmp_path) + (repo / ".mcp.json").write_text( + secret_config, + encoding="utf-8", + ) + with pytest.raises(ObservationBlocked, match="literal credential"): + observe_command( + repo, + ["node", "-e", "process.exit(0)"], + image="node:24-slim", + ) + assert _redact_argv( + [ + "tool", + "--header", + "Authorization: Bearer private-value", + "--token=another-value", + ] + ) == ["tool", "--header", "", "--token="] + + +def test_schema_cli_emits_the_strict_versioned_contract() -> None: + result = CliRunner().invoke(main, ["schema", "declaration"]) + assert result.exit_code == 0 + schema = json.loads(result.output) + assert schema["properties"]["schema_version"]["const"] == ("proof-before-action.declaration.v1") + assert schema["additionalProperties"] is False + + +def test_capsule_index_rejects_path_expansion() -> None: + from pydantic import ValidationError + + from mcp_audit.proof_models import CapsuleIndex + + with pytest.raises(ValidationError, match="exactly capsule.json and report.html"): + CapsuleIndex.model_validate( + { + "schema_version": "proof-before-action.capsule-index.v1", + "capsule_schema_version": CAPSULE_SCHEMA, + "subject_commit": None, + "producer_commit": None, + "artifacts": [ + { + "path": "../../private", + "sha256": "a" * 64, + "bytes": 1, + "content_type": "text/plain", + "logical_role": "evidence", + } + ], + } + ) + + +@requires_docker +def test_cli_inspect_and_verify_the_portable_capsule(tmp_path: Path) -> None: + repo = _repo(tmp_path) + declaration = tmp_path / "declaration.yaml" + declaration.write_text( + """ +schema_version: proof-before-action.declaration.v1 +name: CLI fixture +tools: [node] +permissions: [] +destinations: {files: [], databases: [], network: []} +side_effects: {filesystem: none, database: none, network: none} +limitations: [] +""".strip(), + encoding="utf-8", + ) + output = tmp_path / "capsule" + runner = CliRunner() + inspected = runner.invoke( + main, + [ + "inspect", + "--repo", + str(repo), + "--declaration", + str(declaration), + "--output", + str(output), + "--", + "node", + "-e", + "process.exit(0)", + ], + ) + assert inspected.exit_code == 0, inspected.output + receipt = json.loads(inspected.output) + assert receipt["ok"] is True + assert receipt["verdict"] == "pass" + verified = runner.invoke( + main, + [ + "verify", + str(output), + "--expect-schema", + CAPSULE_SCHEMA, + "--expect-root-sha256", + receipt["root_sha256"], + ], + ) + assert verified.exit_code == 0, verified.output + assert json.loads(verified.output)["authority"] == "anchored" From 9ba1245bf8897b5ec941a8402c19df45ed503a3f Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 00:48:35 -0700 Subject: [PATCH 02/41] test: restore portable Proof Before Action gates --- CHANGELOG.md | 5 +++++ src/mcp_audit/proof_cli.py | 2 +- tests/test_proof_before_action.py | 3 ++- tests/test_safeforge_runtime.py | 25 +++++++++++++++++-------- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f002e6..463ba89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `proof-before-action` — a local-first CLI that compares a declared action + boundary with disposable runtime observations, emits versioned JSON schemas + and an offline evidence capsule, and verifies capsule integrity against + explicit producer commits and independently supplied root hashes. Unknown, + stale, masked, unmatched, or unobservable evidence remains non-authoritative. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/src/mcp_audit/proof_cli.py b/src/mcp_audit/proof_cli.py index e3a51ae..d2b743e 100644 --- a/src/mcp_audit/proof_cli.py +++ b/src/mcp_audit/proof_cli.py @@ -6,7 +6,7 @@ from pathlib import Path import click -import yaml # type: ignore[import-untyped] +import yaml from pydantic import BaseModel, ValidationError from mcp_audit.proof_capsule import ( diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index c385fd0..8912d7d 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -21,6 +21,7 @@ from mcp_audit.proof_models import ( CAPSULE_SCHEMA, ActionDeclaration, + ReleaseTrustManifest, canonical_json_bytes, ) from mcp_audit.proof_observer import ObservationBlocked, _redact_argv, observe_command @@ -61,7 +62,7 @@ def _repo(tmp_path: Path) -> Path: return root -def _empty_trust(repo: Path): +def _empty_trust(repo: Path) -> ReleaseTrustManifest: return build_release_trust_manifest(repo, None) diff --git a/tests/test_safeforge_runtime.py b/tests/test_safeforge_runtime.py index d851b41..17d38b3 100644 --- a/tests/test_safeforge_runtime.py +++ b/tests/test_safeforge_runtime.py @@ -145,6 +145,15 @@ def _runtime_payload() -> dict[str, object]: } +def _sandbox_python() -> str: + for root in (Path("/opt/homebrew/bin"), Path("/usr/local/bin"), Path("/usr/bin")): + for name in ("python3.12", "python3.13", "python3.11", "python3"): + candidate = root / name + if candidate.is_file(): + return str(candidate.resolve()) + raise AssertionError("a system Python outside /Users is required for Seatbelt acceptance") + + def test_runtime_capabilities_match_exact_receipt() -> None: _verify_runtime_capabilities(_receipt(), _runtime_payload()) @@ -261,7 +270,7 @@ def test_supervisor_kills_hanging_shutdown_resistant_process_group( (tmp_path / name).mkdir() monkeypatch.setitem(_LIMITS, "wall_seconds", 1) command = [ - "/usr/local/bin/python3.12", + _sandbox_python(), "-c", "import signal,time; signal.signal(signal.SIGTERM, lambda *_: None); time.sleep(30)", ] @@ -276,7 +285,7 @@ def test_supervisor_enforces_process_count(tmp_path: Path, monkeypatch: pytest.M (tmp_path / name).mkdir() monkeypatch.setitem(_LIMITS, "processes", 2) command = [ - "/usr/local/bin/python3.12", + _sandbox_python(), "-c", "import os,time; [os.fork() for _ in range(4)]; time.sleep(30)", ] @@ -291,7 +300,7 @@ def test_supervisor_enforces_memory_and_disk(tmp_path: Path, monkeypatch: pytest (tmp_path / name).mkdir() monkeypatch.setitem(_LIMITS, "memory_bytes", 10_000_000) memory = _run_sandboxed( - ["/usr/local/bin/python3.12", "-c", "import time; time.sleep(30)"], + [_sandbox_python(), "-c", "import time; time.sleep(30)"], tmp_path, tmp_path / "artifact", deny_network=True, @@ -302,7 +311,7 @@ def test_supervisor_enforces_memory_and_disk(tmp_path: Path, monkeypatch: pytest monkeypatch.setitem(_LIMITS, "disk_bytes", 1_000_000) disk = _run_sandboxed( [ - "/usr/local/bin/python3.12", + _sandbox_python(), "-c", "from pathlib import Path; import time; " 'Path("large").write_bytes(b"x"*5_000_000); time.sleep(30)', @@ -332,7 +341,7 @@ def test_kernel_denies_artifact_root_escape_and_network(tmp_path: Path) -> None: blocked += 1 raise SystemExit(0 if blocked == 3 else 1)""" result = _run_sandboxed( - ["/usr/local/bin/python3.12", "-c", code], + [_sandbox_python(), "-c", code], tmp_path, tmp_path / "artifact", deny_network=True, @@ -349,7 +358,7 @@ def test_kernel_enforces_cpu_limit_and_crash_is_contained( (tmp_path / name).mkdir() monkeypatch.setitem(_LIMITS, "cpu_seconds", 1) cpu = _run_sandboxed( - ["/usr/local/bin/python3.12", "-c", "while True: pass"], + [_sandbox_python(), "-c", "while True: pass"], tmp_path, tmp_path / "artifact", deny_network=True, @@ -357,7 +366,7 @@ def test_kernel_enforces_cpu_limit_and_crash_is_contained( assert cpu.returncode != 0 crash = _run_sandboxed( - ["/usr/local/bin/python3.12", "-c", "import os; os.abort()"], + [_sandbox_python(), "-c", "import os; os.abort()"], tmp_path, tmp_path / "artifact", deny_network=True, @@ -370,7 +379,7 @@ def test_generated_code_profile_kernel_denies_child_processes(tmp_path: Path) -> for name in ("home", "cache", "tmp", "evidence", "artifact"): (tmp_path / name).mkdir() result = _run_sandboxed( - ["/usr/local/bin/python3.12", "-c", _FORK_PROBE_CODE], + [_sandbox_python(), "-c", _FORK_PROBE_CODE], tmp_path, tmp_path / "artifact", deny_network=True, From 860cd991e49320c4ad98845df5e9f86e642c4f2e Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 00:58:13 -0700 Subject: [PATCH 03/41] fix: ignore macOS metadata during proof staging --- src/mcp_audit/proof_observer.py | 1 + tests/test_proof_before_action.py | 1 + 2 files changed, 2 insertions(+) diff --git a/src/mcp_audit/proof_observer.py b/src/mcp_audit/proof_observer.py index 38c04da..d16beef 100644 --- a/src/mcp_audit/proof_observer.py +++ b/src/mcp_audit/proof_observer.py @@ -31,6 +31,7 @@ ) _IGNORED_NAMES = { + ".DS_Store", ".git", ".venv", "node_modules", diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index 8912d7d..a14771a 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -431,6 +431,7 @@ def test_capsule_index_rejects_path_expansion() -> None: @requires_docker def test_cli_inspect_and_verify_the_portable_capsule(tmp_path: Path) -> None: repo = _repo(tmp_path) + (repo / ".DS_Store").write_bytes(b"\0ignored macOS metadata") declaration = tmp_path / "declaration.yaml" declaration.write_text( """ From 7b2366b11f744560134bb19efbb0f4260f53e9d1 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 01:00:06 -0700 Subject: [PATCH 04/41] fix: exclude generated local metadata from proof input --- src/mcp_audit/proof_observer.py | 6 ++++++ tests/test_proof_before_action.py | 3 +++ 2 files changed, 9 insertions(+) diff --git a/src/mcp_audit/proof_observer.py b/src/mcp_audit/proof_observer.py index d16beef..8f7e5c2 100644 --- a/src/mcp_audit/proof_observer.py +++ b/src/mcp_audit/proof_observer.py @@ -32,8 +32,14 @@ _IGNORED_NAMES = { ".DS_Store", + ".coverage", ".git", + ".mypy_cache", + ".nox", + ".serena", + ".tox", ".venv", + "htmlcov", "node_modules", "__pycache__", ".pytest_cache", diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index a14771a..a10d4d0 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -432,6 +432,9 @@ def test_capsule_index_rejects_path_expansion() -> None: def test_cli_inspect_and_verify_the_portable_capsule(tmp_path: Path) -> None: repo = _repo(tmp_path) (repo / ".DS_Store").write_bytes(b"\0ignored macOS metadata") + (repo / ".coverage").write_bytes(b"\0ignored coverage data") + (repo / ".mypy_cache").mkdir() + (repo / ".mypy_cache/cache").write_bytes(b"\0ignored type-checker data") declaration = tmp_path / "declaration.yaml" declaration.write_text( """ From 1e47949a80d90ebd6337a1e9617ef7655a69ef30 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 01:02:38 -0700 Subject: [PATCH 05/41] fix: distinguish GitHub OIDC permissions from secrets --- src/mcp_audit/proof_observer.py | 9 ++++++--- tests/test_proof_before_action.py | 6 ++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/mcp_audit/proof_observer.py b/src/mcp_audit/proof_observer.py index 8f7e5c2..4cb75d6 100644 --- a/src/mcp_audit/proof_observer.py +++ b/src/mcp_audit/proof_observer.py @@ -73,8 +73,8 @@ r"private[_-]?key|secret|token)(?:$|[_-])" ) _TEXT_SECRET_ASSIGNMENT = re.compile( - r"(?im)^\s*[A-Za-z0-9._-]*(?:api[_-]?key|auth|authorization|cookie|credential|" - r"password|private[_-]?key|secret|token)[A-Za-z0-9._-]*\s*[:=]\s*" + r"(?im)^\s*([A-Za-z0-9._-]*(?:api[_-]?key|auth|authorization|cookie|credential|" + r"password|private[_-]?key|secret|token)[A-Za-z0-9._-]*)\s*[:=]\s*" r"[\"']?([^\"'#\r\n]+)" ) _SAFE_DATABASE_NAME = re.compile(r"(?i)(?:fixture|sample|seed|synthetic|test)") @@ -404,7 +404,10 @@ def _validate_staged_input(path: Path, relative: Path) -> None: if payload is not None and _json_contains_literal_secret(payload): raise ObservationBlocked(f"repository JSON contains a literal credential: {relative.as_posix()}") for match in _TEXT_SECRET_ASSIGNMENT.finditer(text): - if not _is_placeholder(match.group(1)): + key, match_value = match.groups() + if key.lower() == "id-token" and match_value.strip().lower() in {"none", "read", "write"}: + continue + if not _is_placeholder(match_value): raise ObservationBlocked( f"repository text contains a literal credential assignment: {relative.as_posix()}" ) diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index a10d4d0..9aa5ff5 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -368,6 +368,7 @@ def test_sensitive_repository_input_is_blocked_before_execution(tmp_path: Path) [ '{"mcpServers":{"unsafe":{"command":"node","env":{"OPENAI_API_KEY":"sk-proj-literal"}}}}', '{"mcpServers":{"unsafe":{"url":"https://example.test","headers":{"X-API-Key":"literal"}}}}', + "id-token: literal-value", "OPENAI_API_KEY: literal-value", ], ) @@ -435,6 +436,11 @@ def test_cli_inspect_and_verify_the_portable_capsule(tmp_path: Path) -> None: (repo / ".coverage").write_bytes(b"\0ignored coverage data") (repo / ".mypy_cache").mkdir() (repo / ".mypy_cache/cache").write_bytes(b"\0ignored type-checker data") + (repo / ".github/workflows").mkdir(parents=True) + (repo / ".github/workflows/publish.yml").write_text( + "permissions:\n id-token: write\n", + encoding="utf-8", + ) declaration = tmp_path / "declaration.yaml" declaration.write_text( """ From fb94dbab0ff2280c2b76451592d4981939c0d42a Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 01:06:18 -0700 Subject: [PATCH 06/41] fix: parse credential assignments without source false positives --- src/mcp_audit/proof_observer.py | 27 ++++++++++++++++++--------- tests/test_proof_before_action.py | 26 ++++++++++++++++++-------- 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/mcp_audit/proof_observer.py b/src/mcp_audit/proof_observer.py index 4cb75d6..f5738c0 100644 --- a/src/mcp_audit/proof_observer.py +++ b/src/mcp_audit/proof_observer.py @@ -79,11 +79,13 @@ ) _SAFE_DATABASE_NAME = re.compile(r"(?i)(?:fixture|sample|seed|synthetic|test)") _PLACEHOLDER_VALUE = re.compile( - r"(?i)^(?:\$\{?[A-Z0-9_]+\}?|<[^>]+>|changeme|dummy|example|fixture|" + r"(?i)^(?:\$\{?[A-Z0-9_]+\}?|\$\{\{\s*(?:env|secrets|vars)\.[A-Z0-9_.-]+\s*\}\}|" + r"<[^>]+>|changeme|dummy|example|fixture|" r"placeholder|redacted|sample|synthetic|test)$" ) _DATABASE_SUFFIXES = {".db", ".sqlite", ".sqlite3"} _REPO_CONFIG_NAMES = {".mcp.json", "server.json"} +_TEXT_CONFIG_SUFFIXES = {".cfg", ".conf", ".ini", ".properties", ".toml", ".yaml", ".yml"} _MAX_FILES = 10_000 _MAX_INPUT_BYTES = 512 * 1024 * 1024 _MAX_OUTPUT_BYTES = 256 * 1024 @@ -403,14 +405,21 @@ def _validate_staged_input(path: Path, relative: Path) -> None: payload = None if payload is not None and _json_contains_literal_secret(payload): raise ObservationBlocked(f"repository JSON contains a literal credential: {relative.as_posix()}") - for match in _TEXT_SECRET_ASSIGNMENT.finditer(text): - key, match_value = match.groups() - if key.lower() == "id-token" and match_value.strip().lower() in {"none", "read", "write"}: - continue - if not _is_placeholder(match_value): - raise ObservationBlocked( - f"repository text contains a literal credential assignment: {relative.as_posix()}" - ) + if path.suffix.lower() in _TEXT_CONFIG_SUFFIXES: + for match in _TEXT_SECRET_ASSIGNMENT.finditer(text): + key, match_value = match.groups() + if not _SENSITIVE_KEY.search(key): + continue + normalized_key = key.lower() + normalized_value = match_value.strip().lower() + if normalized_key == "id-token" and normalized_value in {"none", "read", "write"}: + continue + if normalized_key == "persist-credentials" and normalized_value == "false": + continue + if not _is_placeholder(match_value): + raise ObservationBlocked( + f"repository text contains a literal credential assignment: {relative.as_posix()}" + ) def _json_contains_literal_secret(value: Any) -> bool: diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index 9aa5ff5..7bd178b 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -364,19 +364,25 @@ def test_sensitive_repository_input_is_blocked_before_execution(tmp_path: Path) @pytest.mark.parametrize( - "secret_config", + ("config_name", "secret_config"), [ - '{"mcpServers":{"unsafe":{"command":"node","env":{"OPENAI_API_KEY":"sk-proj-literal"}}}}', - '{"mcpServers":{"unsafe":{"url":"https://example.test","headers":{"X-API-Key":"literal"}}}}', - "id-token: literal-value", - "OPENAI_API_KEY: literal-value", + ( + ".mcp.json", + '{"mcpServers":{"unsafe":{"command":"node","env":{"OPENAI_API_KEY":"sk-proj-literal"}}}}', + ), + ( + ".mcp.json", + '{"mcpServers":{"unsafe":{"url":"https://example.test","headers":{"X-API-Key":"literal"}}}}', + ), + ("unsafe.yml", "id-token: literal-value"), + ("unsafe.yml", "OPENAI_API_KEY: literal-value"), ], ) def test_literal_config_secret_and_sensitive_argv_are_redacted_or_blocked( - tmp_path: Path, secret_config: str + tmp_path: Path, config_name: str, secret_config: str ) -> None: repo = _repo(tmp_path) - (repo / ".mcp.json").write_text( + (repo / config_name).write_text( secret_config, encoding="utf-8", ) @@ -438,7 +444,11 @@ def test_cli_inspect_and_verify_the_portable_capsule(tmp_path: Path) -> None: (repo / ".mypy_cache/cache").write_bytes(b"\0ignored type-checker data") (repo / ".github/workflows").mkdir(parents=True) (repo / ".github/workflows/publish.yml").write_text( - "permissions:\n id-token: write\n", + "permissions:\n" + " id-token: write\n" + "steps:\n" + " persist-credentials: false\n" + " token: ${{ secrets.TOKEN }}\n", encoding="utf-8", ) declaration = tmp_path / "declaration.yaml" From bf51748bdef0bdd8adaa5258a61ae0680598de16 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 01:21:39 -0700 Subject: [PATCH 07/41] fix: redact private argv paths and restore Linux CI --- .github/workflows/ci.yml | 3 +++ src/mcp_audit/proof_observer.py | 20 ++++++++++++++++---- tests/test_proof_before_action.py | 21 ++++++++++++++++++++- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8034ea2..07a8467 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,9 @@ jobs: - name: Type check (mypy) run: uv run mypy . + - name: Prepare local observer image + run: docker pull node:24-slim + - name: Unit tests run: uv run pytest tests/ -v --ignore=tests/test_connector.py --cov=mcp_audit diff --git a/src/mcp_audit/proof_observer.py b/src/mcp_audit/proof_observer.py index f5738c0..1e16517 100644 --- a/src/mcp_audit/proof_observer.py +++ b/src/mcp_audit/proof_observer.py @@ -68,6 +68,7 @@ r"://[^/@\s]+:[^/@\s]+@|" r"\b(?:AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,})\b" ) +_HOME_PATH = re.compile(r"(? 600: raise ObservationBlocked("timeout must be between 1 and 600 seconds") - root = Path(tempfile.mkdtemp(prefix="proof-before-action-", dir="/private/tmp")) + root = Path(tempfile.mkdtemp(prefix="proof-before-action-")) staged = root / "staged" collected = root / "collected" evidence = root / "evidence" @@ -276,11 +277,12 @@ def observe_command( "Transient transactions that leave no SQLite or journal delta are not observable.", ], ) + recorded_argv, recorded_argv_sha256 = _command_argv_evidence(command) return Observation( isolation=isolation, command=CommandEvidence( - argv=_redact_argv(command), - argv_sha256=sha256_bytes(canonical_json_bytes(command)), + argv=recorded_argv, + argv_sha256=recorded_argv_sha256, executable=Path(command[0]).name, exit_code=exit_code, timed_out=timed_out, @@ -366,12 +368,22 @@ def _redact_argv(argv: list[str]) -> list[str]: if _SENSITIVE_VALUE.search(value): redacted.append("") continue - redacted.append(value) + redacted.append(_HOME_PATH.sub(_home_path_replacement, value)) if _SENSITIVE_ARGUMENT.search(value): redact_next = True return redacted +def _home_path_replacement(match: re.Match[str]) -> str: + parts = match.group(0).split("/", 3) + return "$HOME" + ("/" + parts[3] if len(parts) == 4 else "") + + +def _command_argv_evidence(argv: list[str]) -> tuple[list[str], str]: + redacted = _redact_argv(argv) + return redacted, sha256_bytes(canonical_json_bytes(redacted)) + + def _validate_staged_input(path: Path, relative: Path) -> None: if path.suffix.lower() in _DATABASE_SUFFIXES: with path.open("rb") as database: diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index 7bd178b..06e4cde 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -23,8 +23,14 @@ ActionDeclaration, ReleaseTrustManifest, canonical_json_bytes, + sha256_bytes, +) +from mcp_audit.proof_observer import ( + ObservationBlocked, + _command_argv_evidence, + _redact_argv, + observe_command, ) -from mcp_audit.proof_observer import ObservationBlocked, _redact_argv, observe_command from mcp_audit.proof_trust import build_release_trust_manifest DOCKER_READY = ( @@ -400,6 +406,19 @@ def test_literal_config_secret_and_sensitive_argv_are_redacted_or_blocked( "--token=another-value", ] ) == ["tool", "--header", "", "--token="] + private_argv = [ + "node", + "/Users/alice/Projects/private-tool/run.js", + "--config=/home/bob/.config/private.json", + ] + recorded_argv, recorded_digest = _command_argv_evidence(private_argv) + assert recorded_argv == [ + "node", + "$HOME/Projects/private-tool/run.js", + "--config=$HOME/.config/private.json", + ] + assert recorded_digest == sha256_bytes(canonical_json_bytes(recorded_argv)) + assert recorded_digest != sha256_bytes(canonical_json_bytes(private_argv)) def test_schema_cli_emits_the_strict_versioned_contract() -> None: From 6a920e22715c5651a79f8065de5f6ca7be00cd33 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 01:32:46 -0700 Subject: [PATCH 08/41] fix: honor database-only declarations and malformed transports --- src/mcp_audit/proof_capsule.py | 8 ++++--- src/mcp_audit/proof_trust.py | 15 +++++++++++- tests/test_proof_before_action.py | 40 +++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/mcp_audit/proof_capsule.py b/src/mcp_audit/proof_capsule.py index 433e826..1b02e65 100644 --- a/src/mcp_audit/proof_capsule.py +++ b/src/mcp_audit/proof_capsule.py @@ -38,6 +38,8 @@ def compare_bill(declaration: ActionDeclaration, observation: Observation) -> Bi capabilities: list[str] = [] findings: list[ComparisonFinding] = [] executable = observation.command.executable + database_paths = {item.path for item in observation.database_changes} + non_database_file_changes = [item for item in observation.file_changes if item.path not in database_paths] if executable not in declaration.tools: findings.append( ComparisonFinding( @@ -47,7 +49,7 @@ def compare_bill(declaration: ActionDeclaration, observation: Observation) -> Bi evidence=[executable], ) ) - if observation.file_changes: + if non_database_file_changes: capabilities.append("file_write") if declaration.side_effects.filesystem != "write" and "file_write" not in declaration.permissions: findings.append( @@ -55,12 +57,12 @@ def compare_bill(declaration: ActionDeclaration, observation: Observation) -> Bi code="undeclared_file_write", severity="error", message="the command changed files without declaring file-write authority", - evidence=[item.path for item in observation.file_changes], + evidence=[item.path for item in non_database_file_changes], ) ) outside = [ item.path - for item in observation.file_changes + for item in non_database_file_changes if declaration.destinations.files and not any(fnmatch.fnmatch(item.path, pattern) for pattern in declaration.destinations.files) ] diff --git a/src/mcp_audit/proof_trust.py b/src/mcp_audit/proof_trust.py index 4394518..637198b 100644 --- a/src/mcp_audit/proof_trust.py +++ b/src/mcp_audit/proof_trust.py @@ -196,12 +196,25 @@ def discover_repo_mcp( kind = "npm" if registry == "npm" else "pypi" if registry == "pypi" else "unknown" name = str(package.get("identifier", "")) version = str(package.get("version", "")) or None + transport_payload = package.get("transport", {}) + if isinstance(transport_payload, dict): + transport = str(transport_payload.get("type", "unknown")) + else: + diagnostics.append( + DiscoveryDiagnostic( + source_path="server.json", + source_pointer=f"/packages/{index}/transport", + code="invalid_entry", + message="package transport must be an object", + ) + ) + transport = "unknown" dependencies.append( _occurrence( source_path="server.json", source_pointer=f"/packages/{index}", config_name=str(payload.get("name", name)), - transport=str(package.get("transport", {}).get("type", "unknown")), + transport=transport, identity_kind=kind, identity_name=_normalize_package(name, kind), requested_version=version, diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index 06e4cde..4b0cd57 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -129,6 +129,46 @@ def test_seeded_sqlite_mutation_is_semantically_detected(tmp_path: Path) -> None assert change.changed_tables == ["items"] comparison = compare_bill(_declaration(), observation) assert "undeclared_database_write" in {item.code for item in comparison.findings} + declared_database_write = _declaration( + destinations={"files": [], "databases": ["seeded.db"], "network": []}, + side_effects={"filesystem": "none", "database": "write", "network": "none"}, + ) + declared_comparison = compare_bill(declared_database_write, observation) + assert declared_comparison.verdict == "pass" + assert declared_comparison.observed_capabilities == ["database_write"] + + +@pytest.mark.parametrize("transport", [None, "stdio"]) +def test_server_descriptor_scalar_transport_is_a_partial_diagnostic( + tmp_path: Path, transport: object +) -> None: + repo = _repo(tmp_path) + (repo / "server.json").write_text( + json.dumps( + { + "name": "fixture", + "packages": [ + { + "identifier": "@fixture/server", + "registryType": "npm", + "transport": transport, + "version": "1.0.0", + } + ], + } + ), + encoding="utf-8", + ) + manifest = build_release_trust_manifest(repo, None) + assert manifest.discovery_coverage == "partial" + assert manifest.dependencies[0].transport == "unknown" + assert [(item.source_pointer, item.code, item.message) for item in manifest.diagnostics] == [ + ( + "/packages/0/transport", + "invalid_entry", + "package transport must be an object", + ) + ] @requires_docker From ab64774ba013b24646c74b7c4aa3df8718ee05e1 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 01:47:38 -0700 Subject: [PATCH 09/41] Handle Docker observer timeouts safely --- src/mcp_audit/proof_observer.py | 36 ++++++++++++++++------ tests/test_proof_before_action.py | 51 +++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/src/mcp_audit/proof_observer.py b/src/mcp_audit/proof_observer.py index 1e16517..fa55282 100644 --- a/src/mcp_audit/proof_observer.py +++ b/src/mcp_audit/proof_observer.py @@ -12,6 +12,7 @@ import sqlite3 import stat import subprocess +import sys import tarfile import tempfile import time @@ -309,13 +310,25 @@ def observe_command( ], ) finally: + cleanup_errors: list[str] = [] if container_id: - _run(["docker", "rm", "-f", container_id], timeout=20) + try: + _run(["docker", "rm", "-f", container_id], timeout=20) + except (OSError, ObservationBlocked) as exc: + cleanup_errors.append(str(exc)) if staging_container_id: - _run(["docker", "rm", "-f", staging_container_id], timeout=20) + try: + _run(["docker", "rm", "-f", staging_container_id], timeout=20) + except (OSError, ObservationBlocked) as exc: + cleanup_errors.append(str(exc)) if runtime_image: - _run(["docker", "image", "rm", "-f", runtime_image], timeout=30) + try: + _run(["docker", "image", "rm", "-f", runtime_image], timeout=30) + except (OSError, ObservationBlocked) as exc: + cleanup_errors.append(str(exc)) shutil.rmtree(root, ignore_errors=True) + if cleanup_errors and sys.exc_info()[0] is None: + raise ObservationBlocked("disposable Docker cleanup could not be confirmed: " + cleanup_errors[0]) def _stage_repository(source: Path, destination: Path) -> None: @@ -860,13 +873,16 @@ def _sha256_file(path: Path) -> str: def _run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[bytes]: - return subprocess.run( - argv, - check=False, - capture_output=True, - timeout=timeout, - env={"PATH": os.environ.get("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")}, - ) + try: + return subprocess.run( + argv, + check=False, + capture_output=True, + timeout=timeout, + env={"PATH": os.environ.get("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")}, + ) + except subprocess.TimeoutExpired as exc: + raise ObservationBlocked(f"Docker command timed out after {timeout} seconds") from exc def _safe_error(value: bytes) -> str: diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index 4b0cd57..7bdc410 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -72,6 +72,57 @@ def _empty_trust(repo: Path) -> ReleaseTrustManifest: return build_release_trust_manifest(repo, None) +def test_cli_docker_timeout_is_a_structured_inspection_block( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo = _repo(tmp_path) + declaration = tmp_path / "declaration.yaml" + declaration.write_text( + """ +schema_version: proof-before-action.declaration.v1 +name: timeout fixture +tools: [node] +permissions: [] +destinations: {files: [], databases: [], network: []} +side_effects: {filesystem: none, database: none, network: none} +limitations: [] +""".strip(), + encoding="utf-8", + ) + + def time_out(*args: object, **kwargs: object) -> subprocess.CompletedProcess[bytes]: + raise subprocess.TimeoutExpired(cmd=["docker"], timeout=20) + + monkeypatch.setattr(subprocess, "run", time_out) + result = CliRunner().invoke( + main, + [ + "inspect", + "--repo", + str(repo), + "--declaration", + str(declaration), + "--output", + str(tmp_path / "capsule"), + "--", + "node", + "-e", + "process.exit(0)", + ], + ) + assert result.exit_code == 2 + payload = json.loads(result.output) + assert payload == { + "ok": False, + "error": { + "code": "inspection_blocked", + "message": "Docker command timed out after 20 seconds", + }, + } + assert result.exception is not None + assert "Traceback" not in result.output + + @requires_docker def test_read_only_command_passes_and_is_deterministic(tmp_path: Path) -> None: repo = _repo(tmp_path) From df52cf45f802a061b8839fbd8d7b665eef88a188 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 02:00:44 -0700 Subject: [PATCH 10/41] Fail closed on unconfirmed observer cleanup --- src/mcp_audit/proof_observer.py | 38 ++++++++++++++++++++----------- tests/test_proof_before_action.py | 23 +++++++++++++++++++ 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/mcp_audit/proof_observer.py b/src/mcp_audit/proof_observer.py index fa55282..a21681c 100644 --- a/src/mcp_audit/proof_observer.py +++ b/src/mcp_audit/proof_observer.py @@ -312,21 +312,16 @@ def observe_command( finally: cleanup_errors: list[str] = [] if container_id: - try: - _run(["docker", "rm", "-f", container_id], timeout=20) - except (OSError, ObservationBlocked) as exc: - cleanup_errors.append(str(exc)) + if error := _cleanup_docker_resource(["docker", "rm", "-f", container_id], timeout=20): + cleanup_errors.append(error) if staging_container_id: - try: - _run(["docker", "rm", "-f", staging_container_id], timeout=20) - except (OSError, ObservationBlocked) as exc: - cleanup_errors.append(str(exc)) + if error := _cleanup_docker_resource(["docker", "rm", "-f", staging_container_id], timeout=20): + cleanup_errors.append(error) if runtime_image: - try: - _run(["docker", "image", "rm", "-f", runtime_image], timeout=30) - except (OSError, ObservationBlocked) as exc: - cleanup_errors.append(str(exc)) - shutil.rmtree(root, ignore_errors=True) + if error := _cleanup_docker_resource(["docker", "image", "rm", "-f", runtime_image], timeout=30): + cleanup_errors.append(error) + if error := _cleanup_local_root(root): + cleanup_errors.append(error) if cleanup_errors and sys.exc_info()[0] is None: raise ObservationBlocked("disposable Docker cleanup could not be confirmed: " + cleanup_errors[0]) @@ -885,6 +880,23 @@ def _run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[bytes] raise ObservationBlocked(f"Docker command timed out after {timeout} seconds") from exc +def _cleanup_docker_resource(argv: list[str], *, timeout: int) -> str | None: + try: + result = _run(argv, timeout=timeout) + except (OSError, ObservationBlocked) as exc: + return str(exc) + if result.returncode != 0: + return f"Docker cleanup command failed with exit code {result.returncode}" + return None + + +def _cleanup_local_root(root: Path) -> str | None: + shutil.rmtree(root, ignore_errors=True) + if root.exists(): + return "local temporary evidence root still exists after cleanup" + return None + + def _safe_error(value: bytes) -> str: text = value[:4096].decode("utf-8", errors="replace") return text.replace(str(Path.home()), "$HOME") diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index 7bdc410..d2f2994 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -27,6 +27,8 @@ ) from mcp_audit.proof_observer import ( ObservationBlocked, + _cleanup_docker_resource, + _cleanup_local_root, _command_argv_evidence, _redact_argv, observe_command, @@ -123,6 +125,27 @@ def time_out(*args: object, **kwargs: object) -> subprocess.CompletedProcess[byt assert "Traceback" not in result.output +def test_cleanup_readback_fails_closed_for_nonzero_docker_and_remaining_local_root( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + failed = subprocess.CompletedProcess[bytes]( + args=["docker", "rm", "-f", "fixture"], + returncode=1, + stdout=b"", + stderr=b"daemon unavailable", + ) + monkeypatch.setattr("mcp_audit.proof_observer._run", lambda argv, timeout: failed) + assert ( + _cleanup_docker_resource(["docker", "rm", "-f", "fixture"], timeout=20) + == "Docker cleanup command failed with exit code 1" + ) + + local_root = tmp_path / "proof-before-action-fixture" + local_root.mkdir() + monkeypatch.setattr(shutil, "rmtree", lambda *args, **kwargs: None) + assert _cleanup_local_root(local_root) == "local temporary evidence root still exists after cleanup" + + @requires_docker def test_read_only_command_passes_and_is_deterministic(tmp_path: Path) -> None: repo = _repo(tmp_path) From 1a5696b5719003cd050415d63f29980ee78bd5a3 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 03:08:06 -0700 Subject: [PATCH 11/41] Harden disposable observer final-state capture --- CHANGELOG.md | 4 + docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md | 19 +- docs/PROOF-BEFORE-ACTION.md | 29 ++- src/mcp_audit/proof_models.py | 15 ++ src/mcp_audit/proof_observer.py | 223 ++++++++++++++++------- tests/test_proof_before_action.py | 97 ++++++++++ 6 files changed, 313 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 463ba89..68a7981 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and an offline evidence capsule, and verifies capsule integrity against explicit producer commits and independently supplied root hashes. Unknown, stale, masked, unmatched, or unobservable evidence remains non-authoritative. + Its minimally capable observer protects evidence from the unprivileged, + capability-free tested command, stops surviving descendants before the final + archive, and fails closed when command identity, quiescence, or cleanup cannot + be confirmed. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md index 38894a5..a89dc3f 100644 --- a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md +++ b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md @@ -23,9 +23,20 @@ boundary equivalent to a fresh mountless VM. - The untrusted command executes only in the container. - Docker image lookup is local-only; the tool never pulls an image. - The runtime container has network mode `none`, a read-only image root, no - host mounts, no forwarded sockets, no inherited host environment, all Linux - capabilities dropped, `no-new-privileges`, UID/GID `65534:65534`, and CPU, - memory, PID, time, and tmpfs bounds. + host mounts, no forwarded sockets, no inherited host environment, + `no-new-privileges`, and CPU, memory, PID, time, and tmpfs bounds. +- A fixed root-owned PID 1 observer retains only `KILL`, `SETGID`, `SETPCAP`, + and `SETUID` so it can protect the evidence tmpfs, empty the tested command's + capability bounding set, launch it as UID/GID `65534:65534`, enforce its + deadline, and terminate surviving descendants. +- The tested command's actual UID/GID tuples, supplementary groups, all five + Linux capability masks, and `NoNewPrivs` value are captured from `/proc` + through a pre-opened evidence descriptor that is closed before the declared + command starts. Any missing or nonconforming profile blocks inspection. +- PID 1 uses a fixed observer `PATH` that excludes the writable workspace, then + verifies that every task of every command descendant is terminal before + streaming one attached workspace/evidence archive. A failed quiescence + readback blocks the inspection. - Container configuration is read back and mismatches block execution. - Known secret-bearing files, detected literal credentials, non-UTF-8/binary assets, databases not clearly named as synthetic SQLite fixtures, and every @@ -49,7 +60,7 @@ boundary equivalent to a fresh mountless VM. | Current Colima VM host sharing | Not a proven isolation boundary | The VM may expose broader host-adjacent state than the runtime container. A hostile-kernel test should use a fresh mountless VM instead. | | macOS Keychain, TCC, XPC, Apple Events, GUI, devices, and host kernel | Unobserved | The Linux fixture cannot justify claims about these surfaces. | | Transient create-delete or write-restore | Unobserved | Final-state hashing can miss an attempt that leaves no persisted delta. | -| Nested or very short-lived child processes | Incompletely observed | The declared top-level executable is bound, but child executable identities and effects can escape complete process attribution. | +| Nested or very short-lived child processes | Final state quiesced; identity attribution incomplete | Surviving descendants are terminated before the final archive, but child executable identities and transient effects are not completely attributed. | | SQLite transactions with no final delta | Unobserved | Semantic comparison proves final content, not every query or transaction attempt. | | Non-SQLite databases | File-level only | Semantic records and remote database effects are unknown. | | Network destination | Unobserved | Namespace counters reveal common IP/TCP/UDP attempts, not the requested hostname or endpoint. | diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index cf3c31b..982b711 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -75,21 +75,32 @@ The observer: synthetic SQLite fixtures into a temporary staging image without `.git`, dependency caches, build output, known secret files, or detected literal credentials; -2. creates a non-root container with no host mount, no forwarded socket, - network mode `none`, a read-only image root, all capabilities dropped, - `no-new-privileges`, and bounded CPU, memory, process, and tmpfs resources; -3. runs the command against a disposable tmpfs workspace; -4. collects file hashes, SQLite schema/row digests, and Linux IP/TCP/UDP counter - deltas while the container remains alive; -5. removes the container and temporary staging image. +2. creates a container with no host mount, no forwarded socket, network mode + `none`, a read-only image root, `no-new-privileges`, and bounded CPU, memory, + process, and tmpfs resources; +3. uses a fixed PID 1 observer with only `KILL`, `SETGID`, `SETPCAP`, and + `SETUID` to open root-owned evidence files, empty the command capability + bounding set, then launch the tested command as + UID/GID `65534:65534` with empty inheritable, permitted, effective, bounding, + and ambient capability sets; the command's actual `/proc` identity, + supplementary groups, capability masks, and `NoNewPrivs` state are captured + through a pre-opened evidence descriptor, closed before the declared command + starts, and validated into every observation; +4. terminates every surviving command descendant, verifies every Linux task is + terminal from `/proc`, and only then streams one attached archive containing + the disposable workspace and root-owned evidence; +5. collects file hashes, SQLite schema/row digests, and Linux IP/TCP/UDP counter + deltas from that quiesced archive; +6. removes the container and temporary staging image. File and SQLite comparisons are complete for persisted regular files that can be collected. `attempted: null` means no attempt could be inferred; it does not mean the action was proven unable to attempt the effect. Network counters distinguish an observed attempt from no counter change, but cannot identify the requested destination. Link or special-file output blocks collection rather than silently -disappearing. Command output is redirected inside the bounded evidence tmpfs -under an OS file-size limit before it is hashed and omitted. +disappearing. The command cannot write the observer-owned evidence tmpfs. +Command output is redirected there by PID 1 under an OS file-size limit before +it is hashed and omitted. ## Release trust manifest diff --git a/src/mcp_audit/proof_models.py b/src/mcp_audit/proof_models.py index 0eb4402..b45fd6d 100644 --- a/src/mcp_audit/proof_models.py +++ b/src/mcp_audit/proof_models.py @@ -97,6 +97,18 @@ class SurfaceObservation(StrictModel): limitations: list[str] = Field(default_factory=list) +class CommandRuntimeProfile(StrictModel): + uids: tuple[Literal[65534], Literal[65534], Literal[65534], Literal[65534]] + gids: tuple[Literal[65534], Literal[65534], Literal[65534], Literal[65534]] + supplementary_groups: list[int] = Field(max_length=0) + capabilities_inheritable: Literal[0] + capabilities_permitted: Literal[0] + capabilities_effective: Literal[0] + capabilities_bounding: Literal[0] + capabilities_ambient: Literal[0] + no_new_privileges: Literal[True] + + class IsolationEvidence(StrictModel): provider: Literal["docker-in-colima"] = "docker-in-colima" image_reference: str @@ -115,6 +127,9 @@ class IsolationEvidence(StrictModel): secrets_forwarded: list[str] = Field(default_factory=list) containment: Literal["partial"] limitations: list[str] = Field(default_factory=list) + observer_user: Literal["0:0"] | None = None + observer_capabilities: list[Literal["KILL", "SETGID", "SETPCAP", "SETUID"]] = Field(default_factory=list) + command_runtime_profile: CommandRuntimeProfile | None = None class CommandEvidence(StrictModel): diff --git a/src/mcp_audit/proof_observer.py b/src/mcp_audit/proof_observer.py index a21681c..c410068 100644 --- a/src/mcp_audit/proof_observer.py +++ b/src/mcp_audit/proof_observer.py @@ -15,12 +15,12 @@ import sys import tarfile import tempfile -import time from pathlib import Path, PurePosixPath from typing import Any, Literal, cast from mcp_audit.proof_models import ( CommandEvidence, + CommandRuntimeProfile, DatabaseChange, FileChange, IsolationEvidence, @@ -92,19 +92,70 @@ _MAX_INPUT_BYTES = 512 * 1024 * 1024 _MAX_OUTPUT_BYTES = 256 * 1024 _MAX_TEXT_FILE_BYTES = 16 * 1024 * 1024 +_RUNTIME_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" _WRAPPER = r""" set -eu cp -R /pba-input/. /workspace/ +chmod -R a+rwX /workspace cat /proc/net/snmp > /pba/network.before ulimit -f 512 set +e -"$@" > /pba/stdout 2> /pba/stderr +timeout --signal=TERM --kill-after=1 "${PBA_TIMEOUT_SECONDS}s" \ + setpriv --reuid=65534 --regid=65534 --clear-groups \ + --bounding-set=-all --inh-caps=-all --ambient-caps=-all --no-new-privs \ + -- /bin/sh -c ' + cat /proc/self/status >&3 || exit 125 + exec 3>&- + exec "$@" + ' proof-before-action-command "$@" \ + 3> /pba/command.status > /pba/stdout 2> /pba/stderr rc=$? set -e +chmod 0400 /pba/command.status +if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then + touch /pba/timed-out +fi +quiescent=false +for _sweep in 1 2 3 4 5; do + kill -KILL -1 2>/dev/null || true + sleep 0.05 + active=false + for status in /proc/[0-9]*/task/[0-9]*/status; do + task=${status%/status} + tid=${task##*/} + [ "$tid" = "$$" ] && continue + if [ ! -r "$status" ]; then + active=true + continue + fi + state= + while read -r key value _rest; do + if [ "$key" = "State:" ]; then + state=$value + break + fi + done < "$status" || active=true + if [ -z "$state" ]; then + active=true + continue + fi + case "$state" in + Z|X|x) ;; + *) active=true ;; + esac + done + if [ "$active" = false ]; then + quiescent=true + break + fi +done +if [ "$quiescent" = false ]; then + exit 3 +fi cat /proc/net/snmp > /pba/network.after printf '%s\n' "$rc" > /pba/exit-code touch /pba/complete -sleep 600 +tar -C / -cf - workspace pba """ @@ -125,11 +176,11 @@ def observe_command( raise ObservationBlocked("timeout must be between 1 and 600 seconds") root = Path(tempfile.mkdtemp(prefix="proof-before-action-")) staged = root / "staged" - collected = root / "collected" - evidence = root / "evidence" + collected_root = root / "collected" + collected = collected_root / "workspace" + evidence = collected_root / "pba" staged.mkdir(mode=0o700) - collected.mkdir(mode=0o700) - evidence.mkdir(mode=0o700) + collected_root.mkdir(mode=0o700) container_id: str | None = None staging_container_id: str | None = None runtime_image: str | None = None @@ -175,7 +226,8 @@ def observe_command( raise ObservationBlocked( "content-addressed staging image failed: " + _safe_error(committed.stderr) ) - _run(["docker", "rm", "-f", staging_container_id], timeout=20) + if error := _cleanup_docker_resource(["docker", "rm", "-f", staging_container_id], timeout=20): + raise ObservationBlocked("staging container cleanup could not be confirmed: " + error) staging_container_id = None create = _run( [ @@ -188,6 +240,14 @@ def observe_command( "--read-only", "--cap-drop", "ALL", + "--cap-add", + "KILL", + "--cap-add", + "SETGID", + "--cap-add", + "SETPCAP", + "--cap-add", + "SETUID", "--security-opt", "no-new-privileges", "--pids-limit", @@ -203,15 +263,19 @@ def observe_command( "--tmpfs", "/workspace:rw,nosuid,nodev,size=536870912,mode=0777", "--tmpfs", - "/pba:rw,noexec,nosuid,nodev,size=8388608,mode=0777", + "/pba:rw,noexec,nosuid,nodev,size=8388608,mode=0700", "--workdir", "/workspace", "--user", - "65534:65534", + "0:0", "--env", "HOME=/nonexistent", "--env", "LANG=C.UTF-8", + "--env", + f"PATH={_RUNTIME_PATH}", + "--env", + f"PBA_TIMEOUT_SECONDS={timeout_seconds}", "--entrypoint", "/bin/sh", runtime_image, @@ -227,23 +291,27 @@ def observe_command( container_id = create.stdout.decode().strip() inspect = _inspect_container(container_id) isolation = _isolation_evidence(image, image_id, inspect) - started = _run(["docker", "start", container_id], timeout=20) - if started.returncode != 0: - raise ObservationBlocked("container start failed: " + _safe_error(started.stderr)) - - timed_out = not _wait_for_completion(container_id, timeout_seconds) - if timed_out: - _run(["docker", "kill", container_id], timeout=10) - if not timed_out: - _collect_tree(container_id, "/workspace", collected, timeout=60) - _collect_tree(container_id, "/pba", evidence, timeout=20) - _run(["docker", "kill", container_id], timeout=10) + attached = _run( + ["docker", "start", "--attach", container_id], + timeout=timeout_seconds + 75, + ) + if attached.returncode != 0: + raise ObservationBlocked( + f"observer wrapper failed with exit code {attached.returncode} " + "before completing evidence collection: " + _safe_error(attached.stderr) + ) + _extract_observation_archive(attached.stdout, collected_root) + if not (evidence / "complete").is_file(): + raise ObservationBlocked("observer wrapper exited before completing evidence collection") + command_runtime_profile = _read_command_runtime_profile(evidence / "command.status") + isolation = isolation.model_copy(update={"command_runtime_profile": command_runtime_profile}) + timed_out = (evidence / "timed-out").is_file() exit_code = _read_exit_code(evidence / "exit-code") if timed_out: exit_code = None - after_files = before_files if timed_out else _file_snapshot(collected) - after_databases = before_databases if timed_out else _database_snapshot(collected) + after_files = _file_snapshot(collected) + after_databases = _database_snapshot(collected) file_changes = _diff_files(before_files, after_files) database_changes = _diff_databases(before_databases, after_databases) network = _network_evidence( @@ -497,57 +565,36 @@ def _require_image_tools(image: str) -> None: "no-new-privileges", "--entrypoint", "/bin/sh", + "--env", + f"PATH={_RUNTIME_PATH}", image, "-c", - "test -r /proc/net/snmp && command -v tar >/dev/null", + "test -r /proc/net/snmp && command -v tar >/dev/null " + "&& command -v timeout >/dev/null && command -v setpriv >/dev/null", ], timeout=20, ) if result.returncode != 0: - raise ObservationBlocked("local image lacks the required sh, tar, or procfs observer") - - -def _wait_for_completion(container_id: str, timeout_seconds: int) -> bool: - deadline = time.monotonic() + timeout_seconds - while time.monotonic() < deadline: - marker = _run( - ["docker", "exec", container_id, "test", "-f", "/pba/complete"], - timeout=5, - ) - if marker.returncode == 0: - return True - state = _run( - ["docker", "inspect", "--format", "{{.State.Running}}", container_id], - timeout=5, + raise ObservationBlocked( + "local image lacks the required sh, tar, timeout, setpriv, or procfs observer" ) - if state.returncode != 0 or state.stdout.decode().strip() != "true": - logs = _run(["docker", "logs", container_id], timeout=10) - raise ObservationBlocked( - "observer wrapper exited before evidence collection: " - + _safe_error(logs.stderr or logs.stdout) - ) - time.sleep(0.1) - return False -def _collect_tree(container_id: str, source: str, destination: Path, *, timeout: int) -> None: - archived = _run( - ["docker", "exec", container_id, "tar", "-C", source, "-cf", "-", "."], - timeout=timeout, - ) - if archived.returncode != 0: - raise ObservationBlocked("runtime evidence collection failed: " + _safe_error(archived.stderr)) +def _extract_observation_archive(value: bytes, destination: Path) -> None: file_count = 0 total_bytes = 0 try: - with tarfile.open(fileobj=io.BytesIO(archived.stdout), mode="r:") as archive: + with tarfile.open(fileobj=io.BytesIO(value), mode="r:") as archive: for member in archive: relative = PurePosixPath(member.name) parts = tuple(part for part in relative.parts if part != ".") - if relative.is_absolute() or ".." in parts: + if ( + relative.is_absolute() + or ".." in parts + or not parts + or parts[0] not in {"workspace", "pba"} + ): raise ObservationBlocked("runtime evidence archive contains an unsafe path") - if not parts: - continue target = destination.joinpath(*parts) if member.isdir(): target.mkdir(parents=True, exist_ok=True) @@ -570,6 +617,46 @@ def _collect_tree(container_id: str, source: str, destination: Path, *, timeout: raise ObservationBlocked("runtime evidence archive is invalid") from exc +def _read_command_runtime_profile(path: Path) -> CommandRuntimeProfile: + try: + fields: dict[str, str] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + key, separator, value = line.partition(":") + if separator: + fields[key] = value.strip() + uids = tuple(int(item) for item in fields["Uid"].split()) + gids = tuple(int(item) for item in fields["Gid"].split()) + groups = [int(item) for item in fields["Groups"].split()] + capability_fields = { + key: int(fields[key], 16) for key in ("CapInh", "CapPrm", "CapEff", "CapBnd", "CapAmb") + } + no_new_privileges = int(fields["NoNewPrivs"]) + except (KeyError, OSError, UnicodeError, ValueError) as exc: + raise ObservationBlocked("tested command runtime profile was unavailable or malformed") from exc + expected_identity = (65534, 65534, 65534, 65534) + if ( + uids != expected_identity + or gids != expected_identity + or groups + or any(capability_fields.values()) + or no_new_privileges != 1 + ): + raise ObservationBlocked( + "tested command runtime profile did not match the required unprivileged identity" + ) + return CommandRuntimeProfile( + uids=(65534, 65534, 65534, 65534), + gids=(65534, 65534, 65534, 65534), + supplementary_groups=groups, + capabilities_inheritable=0, + capabilities_permitted=0, + capabilities_effective=0, + capabilities_bounding=0, + capabilities_ambient=0, + no_new_privileges=True, + ) + + def _inspect_container(container_id: str) -> dict[str, Any]: result = _run(["docker", "inspect", container_id], timeout=20) if result.returncode != 0: @@ -583,6 +670,7 @@ def _isolation_evidence(image: str, image_id: str, inspect: dict[str, Any]) -> I mounts = inspect.get("Mounts", []) network = str(host.get("NetworkMode", "unknown")) cap_drop = {str(item).upper() for item in host.get("CapDrop", [])} + cap_add = {str(item).upper().removeprefix("CAP_") for item in host.get("CapAdd", [])} security = [str(item) for item in host.get("SecurityOpt", [])] root_read_only = bool(host.get("ReadonlyRootfs")) runtime_user = str(inspect.get("Config", {}).get("User", "")) @@ -591,19 +679,26 @@ def _isolation_evidence(image: str, image_id: str, inspect: dict[str, Any]) -> I pids_limit = host.get("PidsLimit") memory_bytes = host.get("Memory") nano_cpus = host.get("NanoCpus") - tmpfs_paths = sorted(str(item) for item in host.get("Tmpfs", {})) + tmpfs = {str(key): str(value) for key, value in host.get("Tmpfs", {}).items()} + expected_tmpfs = { + "/pba": "rw,noexec,nosuid,nodev,size=8388608,mode=0700", + "/tmp": "rw,noexec,nosuid,nodev,size=67108864,mode=1777", + "/workspace": "rw,nosuid,nodev,size=536870912,mode=0777", + } + tmpfs_paths = sorted(tmpfs) if ( network != "none" or "ALL" not in cap_drop or not root_read_only or mounts - or runtime_user != "65534:65534" + or cap_add != {"KILL", "SETGID", "SETPCAP", "SETUID"} + or runtime_user != "0:0" or not no_new_privileges or log_driver != "none" or pids_limit != 128 or memory_bytes != 536870912 or nano_cpus != 1000000000 - or tmpfs_paths != ["/pba", "/tmp", "/workspace"] + or tmpfs != expected_tmpfs ): raise ObservationBlocked( "container isolation readback did not match the required fail-closed profile" @@ -625,12 +720,18 @@ def _isolation_evidence(image: str, image_id: str, inspect: dict[str, Any]) -> I secrets_forwarded=[], containment="partial", limitations=[ + "The fixed PID 1 observer retains only KILL, SETGID, SETPCAP, and SETUID " + "so it can protect evidence, empty the command capability bounding set, " + "enforce the unprivileged command identity, and terminate descendants.", + "The tested command runs as 65534:65534 with an empty capability bounding set.", "The container has no host mounts or forwarded sockets, but it runs inside " "a networked Colima VM.", "A container or VM escape could reach a broader host-adjacent surface; " "hostile-kernel isolation is not proven.", "Loopback remains available inside the isolated network namespace.", ], + observer_user="0:0", + observer_capabilities=["KILL", "SETGID", "SETPCAP", "SETUID"], ) diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index d2f2994..94f2477 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -163,6 +163,103 @@ def test_read_only_command_passes_and_is_deterministic(tmp_path: Path) -> None: assert canonical_json_bytes(first_capsule) == canonical_json_bytes(second_capsule) +@requires_docker +def test_background_descendant_cannot_mutate_after_observation_completion(tmp_path: Path) -> None: + repo = _repo(tmp_path) + (repo / "padding.txt").write_text("x" * (2 * 1024 * 1024), encoding="utf-8") + child = ( + "const fs=require('fs');let sawWorkspaceTar=false;" + "setInterval(()=>{let active=false;" + "for(const entry of fs.readdirSync('/proc')){" + "if(!/^\\d+$/.test(entry))continue;" + "try{const argv=fs.readFileSync(`/proc/${entry}/cmdline`,'utf8').split('\\0');" + "const executable=(argv[0]||'').split('/').pop();" + "if(executable==='tar'&&argv.some(value=>value==='workspace'||value==='/workspace'))" + "active=true;}catch{}}" + "if(active)sawWorkspaceTar=true;" + "if(sawWorkspaceTar&&!active){" + "fs.writeFileSync('late-descendant.txt','evasion');" + "try{fs.writeFileSync('/pba/stdout','attack-completed')}catch{}" + "process.exit(0);}},1)" + ) + command = [ + "node", + "-e", + ( + "const {spawn}=require('child_process');" + f"spawn(process.execPath,['-e',{json.dumps(child)}]," + "{detached:true,stdio:'ignore'}).unref()" + ), + ] + observation = observe_command(repo, command, image="node:24-slim") + assert observation.command.exit_code == 0 + assert observation.command.timed_out is False + assert observation.command.stdout_sha256 == sha256_bytes(b"") + assert observation.file_changes == [] + assert compare_bill(_declaration(), observation).verdict == "pass" + + +@requires_docker +def test_command_timeout_still_emits_fail_closed_observation(tmp_path: Path) -> None: + repo = _repo(tmp_path) + observation = observe_command( + repo, + ["node", "-e", "setTimeout(()=>{},10000)"], + image="node:24-slim", + timeout_seconds=1, + ) + assert observation.command.timed_out is True + assert observation.command.exit_code is None + comparison = compare_bill(_declaration(), observation) + assert comparison.verdict == "block" + assert "command_timeout" in {item.code for item in comparison.findings} + + +@requires_docker +def test_command_is_unprivileged_and_cannot_rewrite_observer_evidence(tmp_path: Path) -> None: + repo = _repo(tmp_path) + code = ( + "const fs=require('fs');" + "if(process.getuid()!==65534||process.getgid()!==65534)process.exit(10);" + "if(process.env.PATH!=='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin')" + "process.exit(13);" + "try{fs.writeFileSync('/pba/network.before','forged');process.exit(11)}" + "catch(error){if(error.code!=='EACCES')process.exit(12)}" + ) + observation = observe_command(repo, ["node", "-e", code], image="node:24-slim") + assert observation.command.exit_code == 0 + assert observation.isolation.runtime_user == "65534:65534" + assert observation.isolation.observer_user == "0:0" + assert observation.isolation.observer_capabilities == [ + "KILL", + "SETGID", + "SETPCAP", + "SETUID", + ] + assert observation.isolation.command_runtime_profile is not None + profile = observation.isolation.command_runtime_profile + assert profile.uids == (65534, 65534, 65534, 65534) + assert profile.gids == (65534, 65534, 65534, 65534) + assert profile.supplementary_groups == [] + assert profile.capabilities_inheritable == 0 + assert profile.capabilities_permitted == 0 + assert profile.capabilities_effective == 0 + assert profile.capabilities_bounding == 0 + assert profile.capabilities_ambient == 0 + assert profile.no_new_privileges is True + assert compare_bill(_declaration(), observation).verdict == "pass" + + +@requires_docker +def test_option_like_command_is_not_consumed_by_setpriv(tmp_path: Path) -> None: + repo = _repo(tmp_path) + observation = observe_command(repo, ["--help"], image="node:24-slim") + assert observation.command.exit_code not in {None, 0} + comparison = compare_bill(_declaration(), observation) + assert comparison.verdict == "block" + assert "command_failed" in {item.code for item in comparison.findings} + + @requires_docker def test_undeclared_file_write_is_detected_and_blocked(tmp_path: Path) -> None: repo = _repo(tmp_path) From 752bd4c8c685fc03527276d377336031d1b54365 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 03:53:20 -0700 Subject: [PATCH 12/41] Fail closed on dirty trust evidence --- CHANGELOG.md | 2 ++ src/mcp_audit/proof_trust.py | 37 +++++++++++++++++++++++ tests/test_proof_before_action.py | 49 +++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68a7981..3550c6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 capability-free tested command, stops surviving descendants before the final archive, and fails closed when command identity, quiescence, or cleanup cannot be confirmed. + Dirty or commit-unbound local mcp-trust sources now downgrade every matched + entry to non-authoritative, detail-withholding `unverifiable` evidence. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/src/mcp_audit/proof_trust.py b/src/mcp_audit/proof_trust.py index 637198b..6d1b9b0 100644 --- a/src/mcp_audit/proof_trust.py +++ b/src/mcp_audit/proof_trust.py @@ -413,6 +413,27 @@ def _join_trust( ) for dependency in dependencies ] + trust_authority_reason: str | None = None + if trust_commit is None: + trust_authority_reason = ( + "mcp-trust source commit could not be verified; entry-level trust evidence is non-authoritative" + ) + elif trust_dirty is not False: + trust_authority_reason = ( + "mcp-trust source worktree is dirty; entry-level trust evidence is non-authoritative" + ) + if trust_authority_reason is not None: + entries = [ + entry.model_copy( + update={ + "evidence": _without_trust_source_authority( + entry.evidence, + trust_authority_reason, + ) + } + ) + for entry in entries + ] limitations = [ "mcp-trust grades describe an observed MCP surface, not runtime safety or endorsement.", "Version applicability is UNKNOWN when mcp-trust evidence is not bound to the " @@ -423,6 +444,8 @@ def _join_trust( ] if trust_dirty: limitations.append("The mcp-trust source worktree is dirty; trust-source authority is UNKNOWN.") + if trust_commit is None: + limitations.append("The mcp-trust source commit is UNKNOWN; trust-source authority is UNKNOWN.") return ReleaseTrustManifest( repository_commit=repository_commit, repository_dirty=repository_dirty, @@ -435,6 +458,20 @@ def _join_trust( ) +def _without_trust_source_authority( + evidence: TrustEvidence, + reason: str, +) -> TrustEvidence: + return TrustEvidence( + state="unverifiable", + match_state=evidence.match_state, + slug=evidence.slug, + network_isolation="unknown", + version_alignment=evidence.version_alignment, + unknown_reasons=list(dict.fromkeys([*evidence.unknown_reasons, reason])), + ) + + def _match_dependency( dependency: DependencyOccurrence, seed: list[dict[str, Any]], diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index 94f2477..cf8b5c2 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -460,9 +460,25 @@ def _trust_fixture(tmp_path: Path) -> Path: (trust / "src/mcp_trust/core/spec_shift_verdicts.json").write_text( '{"format_version":2,"servers":{}}', encoding="utf-8" ) + _commit_trust_fixture(trust, "initial trust fixture") return trust +def _commit_trust_fixture(trust: Path, message: str) -> None: + if not (trust / ".git").is_dir(): + subprocess.run(["git", "init", "-q", str(trust)], check=True) + subprocess.run( + ["git", "-C", str(trust), "config", "user.email", "proof-fixture@example.invalid"], + check=True, + ) + subprocess.run( + ["git", "-C", str(trust), "config", "user.name", "Proof Fixture"], + check=True, + ) + subprocess.run(["git", "-C", str(trust), "add", "."], check=True) + subprocess.run(["git", "-C", str(trust), "commit", "-q", "-m", message], check=True) + + def test_known_unmatched_and_masked_dependencies_are_all_preserved(tmp_path: Path) -> None: repo = _repo(tmp_path) (repo / ".mcp.json").write_text( @@ -508,6 +524,7 @@ def test_stale_trust_evidence_is_historical_not_current(tmp_path: Path) -> None: snapshot["servers"][0]["scanned_at"] = "2025-01-01T00:00:00+00:00" snapshot["generated_at"] = "2025-01-02T00:00:00+00:00" snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8") + _commit_trust_fixture(trust, "stale trust fixture") manifest = build_release_trust_manifest(repo, trust) assert manifest.entries[0].evidence.state == "stale" assert manifest.entries[0].evidence.grade == "B" @@ -516,6 +533,38 @@ def test_stale_trust_evidence_is_historical_not_current(tmp_path: Path) -> None: assert manifest.trust_source.evaluated_at != manifest.trust_source.snapshot_generated_at +def test_dirty_trust_source_cannot_emit_authoritative_grade_details(tmp_path: Path) -> None: + repo = _repo(tmp_path) + (repo / ".mcp.json").write_text( + '{"mcpServers":{"known":{"command":"npx","args":["@fixture/known-mcp"]}}}', + encoding="utf-8", + ) + trust = _trust_fixture(tmp_path) + snapshot_path = trust / "src/mcp_trust/catalog_snapshot.json" + snapshot = json.loads(snapshot_path.read_text()) + snapshot["servers"][0]["grade"] = "A" + snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8") + + manifest = build_release_trust_manifest(repo, trust) + + assert manifest.trust_source is not None + assert manifest.trust_source.dirty is True + evidence = manifest.entries[0].evidence + assert evidence.state == "unverifiable" + assert evidence.match_state == "exact" + assert evidence.grade is None + assert evidence.transparency is None + assert evidence.scanned_at is None + assert evidence.engine is None + assert evidence.engine_version is None + assert evidence.scan_mode is None + assert evidence.network_isolation == "unknown" + assert ( + "mcp-trust source worktree is dirty; entry-level trust evidence is non-authoritative" + in evidence.unknown_reasons + ) + + @requires_docker def test_tampering_and_wrong_commit_or_schema_are_detected(tmp_path: Path) -> None: repo = _repo(tmp_path) From cd6ec4bde3820ff675fb185f63c7f857563200bd Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 04:11:01 -0700 Subject: [PATCH 13/41] Bind trust and producer evidence to source --- CHANGELOG.md | 8 +- docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md | 8 +- docs/PROOF-BEFORE-ACTION.md | 20 ++- mcp_audit_build_backend.py | 205 +++++++++++++++++++++++ pyproject.toml | 8 +- src/mcp_audit/proof_capsule.py | 62 ++++++- src/mcp_audit/proof_models.py | 1 + src/mcp_audit/proof_trust.py | 39 +++++ tests/test_proof_before_action.py | 87 ++++++++++ 9 files changed, 423 insertions(+), 15 deletions(-) create mode 100644 mcp_audit_build_backend.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3550c6b..8982d33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 capability-free tested command, stops surviving descendants before the final archive, and fails closed when command identity, quiescence, or cleanup cannot be confirmed. - Dirty or commit-unbound local mcp-trust sources now downgrade every matched - entry to non-authoritative, detail-withholding `unverifiable` evidence. + Dirty, commit-unbound, ignored/untracked, or commit-mismatched local mcp-trust + inputs now downgrade every matched entry to non-authoritative, + detail-withholding `unverifiable` evidence. Distribution builds embed + producer revision/dirty-state metadata, installed commands cannot inherit an + unrelated ancestor Git commit, and mismatched external roots remain + `authority: unverified`. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md index a89dc3f..ac559b6 100644 --- a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md +++ b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md @@ -51,6 +51,11 @@ boundary equivalent to a fresh mountless VM. - Offline HTML uses escaped text, no JavaScript, and a restrictive CSP. - Capsule, artifact, payload, subject-commit, producer-commit, schema, and optional external-root checks fail closed. +- Distribution builds embed producer revision and dirty-state metadata; source + checkout discovery requires the executing module to be under the exact Git + root instead of accepting an arbitrary ancestor repository. +- Every required mcp-trust input is read back from the recorded trust commit and + compared byte-for-byte before grade details can remain authoritative. ## Residual threats and honest unknowns @@ -69,7 +74,8 @@ boundary equivalent to a fresh mountless VM. | Unknown secret formats or low-entropy secret hashes | Residual risk | Redaction is best effort, and a digest can sometimes be guessed. Review declarations and commands before sharing capsules. | | Malicious local Docker daemon or image | Trusted locally | A local image can contain hostile infrastructure. Pin and independently verify the image digest. | | Internal capsule hashes | Consistency only | They do not prove who authorized the capsule. Record the index root in an external authority channel. | -| mcp-trust grade applicability | Evidence-limited | Stale, masked, missing, version-unbound, or dirty-source evidence remains unknown. | +| mcp-trust grade applicability | Evidence-limited | Stale, masked, missing, version-unbound, dirty, ignored/untracked, or commit-mismatched evidence remains unknown. | +| Producer build metadata | Evidence-limited | A clean embedded revision binds packaged code to its build source claim, but package authenticity still requires a trusted distribution channel or an externally anchored capsule root. | ## False claims the product must not make diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index 982b711..94a06d5 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -65,7 +65,15 @@ proof-before-action verify ./proof-capsule \ Without `--expect-root-sha256`, a successful verification result reports `authority: unverified`: internal hashes cannot establish who authorized the -artifact. +artifact. A supplied root reports `anchored` only when it matches; a mismatch is +invalid and remains `authority: unverified`. + +Wheel and source-distribution builds use the repository's uv-backed PEP 517 +wrapper to embed the exact source revision and pre-build dirty state. Installed +commands read only that packaged metadata; source checkouts use Git only when +the executing module is under that checkout's exact `src/mcp_audit` path. This +prevents an installed virtual environment from inheriting an unrelated ancestor +repository as its producer. ## Observation contract @@ -111,10 +119,12 @@ exact source pointer. Environment and header values are never copied; only key names are retained. The join uses the local mcp-trust catalog snapshot, catalog seed, -`masked-grades.json`, and spec-shift format version. Missing, stale, masked, -ambiguous, unmatched, dirty-source, or version-unbound evidence remains explicit -in the manifest. A grade is historical evidence about an observed MCP surface, -not an endorsement or runtime-safety proof. +`masked-grades.json`, and spec-shift format version. Every required input must be +tracked and byte-identical to the recorded trust commit; ignored or otherwise +untracked files do not count as clean authority. Missing, stale, masked, +ambiguous, unmatched, dirty-source, commit-unbound, or version-unbound evidence +remains explicit in the manifest. A grade is historical evidence about an +observed MCP surface, not an endorsement or runtime-safety proof. Freshness is evaluated at the current UTC date, recorded separately from the snapshot generation timestamp. Runs are byte-stable within that date; evidence diff --git a/mcp_audit_build_backend.py b/mcp_audit_build_backend.py new file mode 100644 index 0000000..f7830e3 --- /dev/null +++ b/mcp_audit_build_backend.py @@ -0,0 +1,205 @@ +"""PEP 517 wrapper that binds built distributions to their source revision.""" + +from __future__ import annotations + +import json +import os +import subprocess +from collections.abc import Iterator, Mapping +from contextlib import contextmanager +from pathlib import Path +from typing import Any, cast + +_ROOT = Path(__file__).resolve().parent +_PACKAGE_ROOT = _ROOT / "src/mcp_audit" +_PROVENANCE_PATH = _PACKAGE_ROOT / "_build_provenance.json" +_SCHEMA = "mcp-audits.build-provenance.v1" + + +def build_sdist( + sdist_directory: str, + config_settings: Mapping[Any, Any] | None = None, +) -> str: + with _generated_provenance(): + from uv_build import build_sdist as uv_build_sdist + + return cast(str, uv_build_sdist(sdist_directory, config_settings)) + + +def build_wheel( + wheel_directory: str, + config_settings: Mapping[Any, Any] | None = None, + metadata_directory: str | None = None, +) -> str: + with _generated_provenance(): + from uv_build import build_wheel as uv_build_wheel + + return cast( + str, + uv_build_wheel(wheel_directory, config_settings, metadata_directory), + ) + + +def build_editable( + wheel_directory: str, + config_settings: Mapping[Any, Any] | None = None, + metadata_directory: str | None = None, +) -> str: + from uv_build import build_editable as uv_build_editable + + return cast( + str, + uv_build_editable(wheel_directory, config_settings, metadata_directory), + ) + + +def get_requires_for_build_sdist( + config_settings: Mapping[Any, Any] | None = None, +) -> list[str]: + from uv_build import get_requires_for_build_sdist + + return list(get_requires_for_build_sdist(config_settings)) + + +def get_requires_for_build_wheel( + config_settings: Mapping[Any, Any] | None = None, +) -> list[str]: + from uv_build import get_requires_for_build_wheel + + return list(get_requires_for_build_wheel(config_settings)) + + +def get_requires_for_build_editable( + config_settings: Mapping[Any, Any] | None = None, +) -> list[str]: + from uv_build import get_requires_for_build_editable + + return list(get_requires_for_build_editable(config_settings)) + + +def prepare_metadata_for_build_wheel( + metadata_directory: str, + config_settings: Mapping[Any, Any] | None = None, +) -> str: + from uv_build import prepare_metadata_for_build_wheel + + return cast( + str, + prepare_metadata_for_build_wheel(metadata_directory, config_settings), + ) + + +def prepare_metadata_for_build_editable( + metadata_directory: str, + config_settings: Mapping[Any, Any] | None = None, +) -> str: + from uv_build import prepare_metadata_for_build_editable + + return cast( + str, + prepare_metadata_for_build_editable(metadata_directory, config_settings), + ) + + +@contextmanager +def _generated_provenance() -> Iterator[None]: + if _PROVENANCE_PATH.exists(): + if _git_root() is not None: + raise RuntimeError( + "source checkout contains pre-generated build provenance; refusing an ambiguous build" + ) + _validate_provenance(_PROVENANCE_PATH) + yield + return + payload = _source_provenance() + _PROVENANCE_PATH.write_text( + json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + try: + yield + finally: + _PROVENANCE_PATH.unlink(missing_ok=True) + + +def _source_provenance() -> dict[str, str | bool | None]: + root = _git_root() + if root is None: + return { + "schema_version": _SCHEMA, + "commit": None, + "dirty": None, + } + commit_result = _git("rev-parse", "HEAD") + status_result = _git("status", "--porcelain", "--untracked-files=all") + if commit_result.returncode != 0 or status_result.returncode != 0: + return { + "schema_version": _SCHEMA, + "commit": None, + "dirty": None, + } + commit = commit_result.stdout.decode().strip() + dirty = bool(status_result.stdout) + tracked_result = _git("ls-files", "-z", "--", "src/mcp_audit") + if tracked_result.returncode != 0: + dirty = True + else: + tracked = {item for item in tracked_result.stdout.decode().split("\0") if item} + actual = { + path.relative_to(_ROOT).as_posix() + for path in _PACKAGE_ROOT.rglob("*") + if path.is_file() + and "__pycache__" not in path.parts + and path.suffix not in {".pyc", ".pyo"} + and path != _PROVENANCE_PATH + } + if not actual.issubset(tracked): + dirty = True + return { + "schema_version": _SCHEMA, + "commit": commit if len(commit) == 40 else None, + "dirty": dirty, + } + + +def _validate_provenance(path: Path) -> None: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise RuntimeError("build provenance is unreadable") from exc + if not isinstance(payload, dict) or payload.get("schema_version") != _SCHEMA: + raise RuntimeError("build provenance schema is unsupported") + commit = payload.get("commit") + dirty = payload.get("dirty") + if commit is not None and ( + not isinstance(commit, str) + or len(commit) != 40 + or any(character not in "0123456789abcdef" for character in commit) + ): + raise RuntimeError("build provenance commit is invalid") + if dirty is not None and not isinstance(dirty, bool): + raise RuntimeError("build provenance dirty state is invalid") + + +def _git_root() -> Path | None: + result = _git("rev-parse", "--show-toplevel") + if result.returncode != 0: + return None + try: + root = Path(result.stdout.decode().strip()).resolve() + except (OSError, UnicodeError): + return None + return root if root == _ROOT else None + + +def _git(*args: str) -> subprocess.CompletedProcess[bytes]: + try: + return subprocess.run( + ["git", "-C", str(_ROOT), *args], + check=False, + capture_output=True, + timeout=10, + env={"PATH": os.environ.get("PATH", "")}, + ) + except (OSError, subprocess.SubprocessError): + return subprocess.CompletedProcess(["git", *args], 1, b"", b"") diff --git a/pyproject.toml b/pyproject.toml index 73ff1ce..01bb48a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,15 +61,21 @@ mcp-audits = "mcp_audit.cli:main" [build-system] requires = ["uv_build>=0.11.2,<0.12.0"] -build-backend = "uv_build" +build-backend = "mcp_audit_build_backend" +backend-path = ["."] [tool.uv.build-backend] module-name = "mcp_audit" +source-include = ["mcp_audit_build_backend.py"] [tool.mypy] strict = true python_version = "3.11" +[[tool.mypy.overrides]] +module = ["uv_build"] +ignore_missing_imports = true + [tool.ruff] target-version = "py311" line-length = 110 diff --git a/src/mcp_audit/proof_capsule.py b/src/mcp_audit/proof_capsule.py index 1b02e65..ddedc6e 100644 --- a/src/mcp_audit/proof_capsule.py +++ b/src/mcp_audit/proof_capsule.py @@ -3,6 +3,7 @@ from __future__ import annotations import fnmatch +import hmac import html import json import os @@ -173,7 +174,7 @@ def build_capsule( comparison: BillComparison, trust_manifest: ReleaseTrustManifest, ) -> EvidenceCapsule: - commit, dirty = _producer_git_state() + commit, dirty, provenance_source = _producer_state() producer_limitations: list[str] = [] if commit is None: producer_limitations.append( @@ -204,6 +205,7 @@ def build_capsule( version=__version__, commit=commit, dirty=dirty, + provenance_source=provenance_source, ), limitations=limitations, ) @@ -407,7 +409,8 @@ def verify_capsule( "message": f"expected {expect_producer_commit}, got {producer_commit}", } ) - if expect_root_sha256 and root_sha256 != expect_root_sha256: + root_matches = bool(expect_root_sha256 and hmac.compare_digest(root_sha256, expect_root_sha256)) + if expect_root_sha256 and not root_matches: errors.append( { "code": "authority_root_mismatch", @@ -417,14 +420,38 @@ def verify_capsule( return { "valid": not errors, "root_sha256": root_sha256, - "authority": "anchored" if expect_root_sha256 else "unverified", + "authority": "anchored" if root_matches else "unverified", "errors": errors, } -def _producer_git_state() -> tuple[str | None, bool | None]: - root = Path(__file__).resolve().parents[2] +def _producer_state() -> tuple[ + str | None, + bool | None, + Literal["build-metadata", "source-checkout"] | None, +]: + embedded = _embedded_build_provenance() + if embedded is not None: + return (*embedded, "build-metadata") + module_path = Path(__file__).resolve() + root = module_path.parents[2] + expected_module = root / "src/mcp_audit/proof_capsule.py" try: + if expected_module.resolve() != module_path: + return None, None, None + except OSError: + return None, None, None + try: + top_level = subprocess.run( + ["git", "-C", str(root), "rev-parse", "--show-toplevel"], + check=True, + capture_output=True, + text=True, + timeout=5, + env={"PATH": os.environ.get("PATH", "")}, + ).stdout.strip() + if Path(top_level).resolve() != root: + return None, None, None commit = subprocess.run( ["git", "-C", str(root), "rev-parse", "HEAD"], check=True, @@ -441,6 +468,29 @@ def _producer_git_state() -> tuple[str | None, bool | None]: timeout=5, env={"PATH": os.environ.get("PATH", "")}, ).stdout - return commit, bool(status) + return commit, bool(status), "source-checkout" except (OSError, subprocess.SubprocessError): + return None, None, None + + +def _embedded_build_provenance() -> tuple[str | None, bool | None] | None: + path = Path(__file__).with_name("_build_provenance.json") + if not path.is_file(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict) or payload.get("schema_version") != "mcp-audits.build-provenance.v1": + return None, None + commit = payload.get("commit") + dirty = payload.get("dirty") + if commit is not None and ( + not isinstance(commit, str) + or len(commit) != 40 + or any(character not in "0123456789abcdef" for character in commit) + ): + return None, None + if dirty is not None and not isinstance(dirty, bool): + return None, None + return commit, dirty + except (OSError, UnicodeError, json.JSONDecodeError): return None, None diff --git a/src/mcp_audit/proof_models.py b/src/mcp_audit/proof_models.py index b45fd6d..96030a5 100644 --- a/src/mcp_audit/proof_models.py +++ b/src/mcp_audit/proof_models.py @@ -277,6 +277,7 @@ class ProducerEvidence(StrictModel): version: str commit: str | None dirty: bool | None + provenance_source: Literal["build-metadata", "source-checkout"] | None = None aigccore_primitive_source_commit: str = "d8c570cf148bb502b7ed0cc7fd58f1e054697180" diff --git a/src/mcp_audit/proof_trust.py b/src/mcp_audit/proof_trust.py index 6d1b9b0..1bb2c6e 100644 --- a/src/mcp_audit/proof_trust.py +++ b/src/mcp_audit/proof_trust.py @@ -385,6 +385,14 @@ def _join_trust( f"mcp-trust source could not be parsed: {type(exc).__name__}", ) trust_commit, trust_dirty = _git_state(trust_root) + trust_inputs_bound = bool( + trust_commit + and _files_match_git_commit( + trust_root, + trust_commit, + list(files.values()), + ) + ) snapshot_generated_at = str(snapshot.get("generated_at", "")) or "unknown" evaluated_at = datetime.now(UTC).date().isoformat() + "T00:00:00+00:00" source = TrustSource( @@ -422,6 +430,11 @@ def _join_trust( trust_authority_reason = ( "mcp-trust source worktree is dirty; entry-level trust evidence is non-authoritative" ) + elif not trust_inputs_bound: + trust_authority_reason = ( + "required mcp-trust inputs are not byte-identical to the trust commit; " + "entry-level trust evidence is non-authoritative" + ) if trust_authority_reason is not None: entries = [ entry.model_copy( @@ -446,6 +459,11 @@ def _join_trust( limitations.append("The mcp-trust source worktree is dirty; trust-source authority is UNKNOWN.") if trust_commit is None: limitations.append("The mcp-trust source commit is UNKNOWN; trust-source authority is UNKNOWN.") + elif not trust_inputs_bound: + limitations.append( + "Required mcp-trust inputs are not byte-identical to the trust commit; " + "trust-source authority is UNKNOWN." + ) return ReleaseTrustManifest( repository_commit=repository_commit, repository_dirty=repository_dirty, @@ -709,6 +727,27 @@ def _git_state(root: Path) -> tuple[str | None, bool | None]: return None, None +def _files_match_git_commit( + root: Path, + commit: str, + paths: list[Path], +) -> bool: + for path in paths: + try: + relative = path.relative_to(root).as_posix() + committed = subprocess.run( + ["git", "-C", str(root), "show", f"{commit}:{relative}"], + check=False, + capture_output=True, + timeout=5, + ) + if committed.returncode != 0 or committed.stdout != path.read_bytes(): + return False + except (OSError, ValueError, subprocess.SubprocessError): + return False + return True + + def _repository_limitations(commit: str | None, dirty: bool | None) -> list[str]: limitations: list[str] = [] if commit is None: diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index cf8b5c2..ba11acf 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -11,6 +11,7 @@ import pytest from click.testing import CliRunner +import mcp_audit.proof_capsule as proof_capsule_module from mcp_audit.proof_capsule import ( build_capsule, compare_bill, @@ -565,6 +566,88 @@ def test_dirty_trust_source_cannot_emit_authoritative_grade_details(tmp_path: Pa ) +def test_ignored_untracked_trust_inputs_cannot_escape_commit_binding(tmp_path: Path) -> None: + repo = _repo(tmp_path) + (repo / ".mcp.json").write_text( + '{"mcpServers":{"known":{"command":"npx","args":["@fixture/known-mcp"]}}}', + encoding="utf-8", + ) + trust = _trust_fixture(tmp_path) + required = [ + "src/mcp_trust/catalog_snapshot.json", + "src/mcp_trust/catalog/seed_servers.json", + "masked-grades.json", + "src/mcp_trust/core/spec_shift_verdicts.json", + ] + subprocess.run( + ["git", "-C", str(trust), "rm", "--cached", "--quiet", "--", *required], + check=True, + ) + (trust / ".gitignore").write_text("\n".join(required) + "\n", encoding="utf-8") + _commit_trust_fixture(trust, "ignore unbound trust inputs") + + manifest = build_release_trust_manifest(repo, trust) + + assert manifest.trust_source is not None + assert manifest.trust_source.dirty is False + evidence = manifest.entries[0].evidence + assert evidence.state == "unverifiable" + assert evidence.grade is None + assert ( + "required mcp-trust inputs are not byte-identical to the trust commit; " + "entry-level trust evidence is non-authoritative" in evidence.unknown_reasons + ) + + +def test_installed_module_does_not_inherit_an_unrelated_ancestor_commit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ancestor = tmp_path / "caller" + ancestor.mkdir() + subprocess.run(["git", "init", "-q", str(ancestor)], check=True) + installed_module = ancestor / ".venv/lib/python3.11/site-packages/mcp_audit/proof_capsule.py" + installed_module.parent.mkdir(parents=True) + installed_module.write_text("# installed fixture\n", encoding="utf-8") + monkeypatch.setattr( + proof_capsule_module, + "__file__", + str(installed_module), + ) + + assert proof_capsule_module._producer_state() == (None, None, None) + + +def test_installed_module_uses_embedded_build_provenance( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + installed_module = tmp_path / "site-packages/mcp_audit/proof_capsule.py" + installed_module.parent.mkdir(parents=True) + installed_module.write_text("# installed fixture\n", encoding="utf-8") + (installed_module.parent / "_build_provenance.json").write_text( + json.dumps( + { + "schema_version": "mcp-audits.build-provenance.v1", + "commit": "a" * 40, + "dirty": False, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr( + proof_capsule_module, + "__file__", + str(installed_module), + ) + + assert proof_capsule_module._producer_state() == ( + "a" * 40, + False, + "build-metadata", + ) + + @requires_docker def test_tampering_and_wrong_commit_or_schema_are_detected(tmp_path: Path) -> None: repo = _repo(tmp_path) @@ -575,6 +658,10 @@ def test_tampering_and_wrong_commit_or_schema_are_detected(tmp_path: Path) -> No output = tmp_path / "capsule" root_sha = export_capsule(capsule, output) assert verify_capsule(output, expect_root_sha256=root_sha)["valid"] is True + wrong_root = verify_capsule(output, expect_root_sha256="0" * 64) + assert wrong_root["valid"] is False + assert wrong_root["authority"] == "unverified" + assert "authority_root_mismatch" in {item["code"] for item in wrong_root["errors"]} wrong = verify_capsule( output, expect_subject_commit="0" * 40, From 1a41b18edaf17f0b716d779ad07de2475138677a Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 04:20:03 -0700 Subject: [PATCH 14/41] Keep runtime and trust evidence honest --- CHANGELOG.md | 3 +++ docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md | 11 +++++--- src/mcp_audit/proof_models.py | 2 +- src/mcp_audit/proof_observer.py | 8 +++--- src/mcp_audit/proof_trust.py | 28 ++++++++++++++++++++- tests/test_proof_before_action.py | 32 ++++++++++++++++++++++++ 6 files changed, 74 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8982d33..3fd8f13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 producer revision/dirty-state metadata, installed commands cannot inherit an unrelated ancestor Git commit, and mismatched external roots remain `authority: unverified`. + New observations report the environment-neutral `docker` provider instead of + assuming Colima, and valid-but-wrong-shaped trust inputs become structured + UNKNOWN manifests rather than tracebacks. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md index ac559b6..a8c8d81 100644 --- a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md +++ b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md @@ -11,12 +11,15 @@ capsule. The implementation has four relevant boundaries: 1. the macOS host and local Docker client; -2. the Colima Linux VM; +2. the Docker engine host and any optional engine VM (Colima on the current + macOS setup); 3. the restricted Docker container; 4. the exported evidence directory. -The container is disposable. The Colima VM is not treated as a security -boundary equivalent to a fresh mountless VM. +The container is disposable. The engine host and any engine VM are not treated +as a security boundary equivalent to a fresh mountless VM. Capsules name the +provider as environment-neutral `docker`; they do not infer a VM from the client +platform. ## Enforced controls @@ -62,7 +65,7 @@ boundary equivalent to a fresh mountless VM. | Threat or surface | Status | Consequence | | --- | --- | --- | | Container, VM, or hypervisor escape | Unknown | Could bypass the container controls. A capsule records containment as `partial`. | -| Current Colima VM host sharing | Not a proven isolation boundary | The VM may expose broader host-adjacent state than the runtime container. A hostile-kernel test should use a fresh mountless VM instead. | +| Docker engine host or optional VM sharing | Not a proven isolation boundary | The engine layer may expose broader host-adjacent state than the runtime container. A hostile-kernel test should use a fresh mountless VM instead. | | macOS Keychain, TCC, XPC, Apple Events, GUI, devices, and host kernel | Unobserved | The Linux fixture cannot justify claims about these surfaces. | | Transient create-delete or write-restore | Unobserved | Final-state hashing can miss an attempt that leaves no persisted delta. | | Nested or very short-lived child processes | Final state quiesced; identity attribution incomplete | Surviving descendants are terminated before the final archive, but child executable identities and transient effects are not completely attributed. | diff --git a/src/mcp_audit/proof_models.py b/src/mcp_audit/proof_models.py index 96030a5..52daa91 100644 --- a/src/mcp_audit/proof_models.py +++ b/src/mcp_audit/proof_models.py @@ -110,7 +110,7 @@ class CommandRuntimeProfile(StrictModel): class IsolationEvidence(StrictModel): - provider: Literal["docker-in-colima"] = "docker-in-colima" + provider: Literal["docker", "docker-in-colima"] = "docker" image_reference: str image_id: str runtime_user: Literal["65534:65534"] diff --git a/src/mcp_audit/proof_observer.py b/src/mcp_audit/proof_observer.py index c410068..12aaa5b 100644 --- a/src/mcp_audit/proof_observer.py +++ b/src/mcp_audit/proof_observer.py @@ -724,10 +724,10 @@ def _isolation_evidence(image: str, image_id: str, inspect: dict[str, Any]) -> I "so it can protect evidence, empty the command capability bounding set, " "enforce the unprivileged command identity, and terminate descendants.", "The tested command runs as 65534:65534 with an empty capability bounding set.", - "The container has no host mounts or forwarded sockets, but it runs inside " - "a networked Colima VM.", - "A container or VM escape could reach a broader host-adjacent surface; " - "hostile-kernel isolation is not proven.", + "The container has no host mounts or forwarded sockets, but the Docker engine " + "and any VM or hypervisor layer are outside the observed boundary.", + "A container escape, or VM escape when a VM is present, could reach a broader " + "host-adjacent surface; hostile-kernel isolation is not proven.", "Loopback remains available inside the isolated network namespace.", ], observer_user="0:0", diff --git a/src/mcp_audit/proof_trust.py b/src/mcp_audit/proof_trust.py index 1bb2c6e..59c33e4 100644 --- a/src/mcp_audit/proof_trust.py +++ b/src/mcp_audit/proof_trust.py @@ -374,7 +374,7 @@ def _join_trust( try: snapshot = json.loads(files["catalog_snapshot.json"].read_text(encoding="utf-8")) seed = json.loads(files["seed_servers.json"].read_text(encoding="utf-8")) - masked = set(json.loads(files["masked-grades.json"].read_text(encoding="utf-8"))) + masked_payload = json.loads(files["masked-grades.json"].read_text(encoding="utf-8")) spec_shift = json.loads(files["spec_shift_verdicts.json"].read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: return _unknown_trust_manifest( @@ -384,6 +384,15 @@ def _join_trust( repository_dirty, f"mcp-trust source could not be parsed: {type(exc).__name__}", ) + if not _valid_trust_input_shapes(snapshot, seed, masked_payload, spec_shift): + return _unknown_trust_manifest( + dependencies, + diagnostics, + repository_commit, + repository_dirty, + "mcp-trust source has an unsupported data shape", + ) + masked = set(masked_payload) trust_commit, trust_dirty = _git_state(trust_root) trust_inputs_bound = bool( trust_commit @@ -476,6 +485,23 @@ def _join_trust( ) +def _valid_trust_input_shapes( + snapshot: Any, + seed: Any, + masked: Any, + spec_shift: Any, +) -> bool: + if not isinstance(snapshot, dict) or not isinstance(spec_shift, dict): + return False + records = snapshot.get("servers") + if not isinstance(records, list) or not all(isinstance(item, dict) for item in records): + return False + seed_rows = seed if isinstance(seed, list) else seed.get("servers") if isinstance(seed, dict) else None + if not isinstance(seed_rows, list) or not all(isinstance(item, dict) for item in seed_rows): + return False + return isinstance(masked, list) and all(isinstance(item, str) for item in masked) + + def _without_trust_source_authority( evidence: TrustEvidence, reason: str, diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index ba11acf..ee8cfce 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -229,6 +229,7 @@ def test_command_is_unprivileged_and_cannot_rewrite_observer_evidence(tmp_path: ) observation = observe_command(repo, ["node", "-e", code], image="node:24-slim") assert observation.command.exit_code == 0 + assert observation.isolation.provider == "docker" assert observation.isolation.runtime_user == "65534:65534" assert observation.isolation.observer_user == "0:0" assert observation.isolation.observer_capabilities == [ @@ -599,6 +600,37 @@ def test_ignored_untracked_trust_inputs_cannot_escape_commit_binding(tmp_path: P ) +@pytest.mark.parametrize( + ("relative", "payload"), + [ + ("src/mcp_trust/catalog_snapshot.json", []), + ("src/mcp_trust/catalog/seed_servers.json", {"servers": {}}), + ("masked-grades.json", {}), + ("src/mcp_trust/core/spec_shift_verdicts.json", []), + ], +) +def test_wrong_shaped_trust_inputs_become_structured_unknown( + tmp_path: Path, + relative: str, + payload: object, +) -> None: + repo = _repo(tmp_path) + (repo / ".mcp.json").write_text( + '{"mcpServers":{"known":{"command":"npx","args":["@fixture/known-mcp"]}}}', + encoding="utf-8", + ) + trust = _trust_fixture(tmp_path) + (trust / relative).write_text(json.dumps(payload), encoding="utf-8") + + manifest = build_release_trust_manifest(repo, trust) + + assert manifest.discovery_coverage == "unknown" + assert manifest.trust_source is None + assert manifest.entries[0].evidence.state == "unverifiable" + assert manifest.entries[0].evidence.grade is None + assert "mcp-trust source has an unsupported data shape" in manifest.limitations + + def test_installed_module_does_not_inherit_an_unrelated_ancestor_commit( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From 7168a161ddd38406e6e6ecd9beab36c9a854ec5b Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 04:33:15 -0700 Subject: [PATCH 15/41] Keep malformed inputs structured --- src/mcp_audit/proof_cli.py | 2 +- src/mcp_audit/proof_trust.py | 6 +++++- tests/test_proof_before_action.py | 33 +++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/mcp_audit/proof_cli.py b/src/mcp_audit/proof_cli.py index d2b743e..c12f48b 100644 --- a/src/mcp_audit/proof_cli.py +++ b/src/mcp_audit/proof_cli.py @@ -73,7 +73,7 @@ def inspect( trust = build_release_trust_manifest(repo, trust_root) capsule = build_capsule(declared, observed, comparison, trust) root_sha256 = export_capsule(capsule, output) - except (OSError, ValueError, ValidationError, ObservationBlocked) as exc: + except (OSError, ValueError, yaml.YAMLError, ValidationError, ObservationBlocked) as exc: click.echo( json.dumps( { diff --git a/src/mcp_audit/proof_trust.py b/src/mcp_audit/proof_trust.py index 59c33e4..a43d794 100644 --- a/src/mcp_audit/proof_trust.py +++ b/src/mcp_audit/proof_trust.py @@ -499,6 +499,8 @@ def _valid_trust_input_shapes( seed_rows = seed if isinstance(seed, list) else seed.get("servers") if isinstance(seed, dict) else None if not isinstance(seed_rows, list) or not all(isinstance(item, dict) for item in seed_rows): return False + if not all(isinstance(item.get("source", {}), dict) for item in seed_rows): + return False return isinstance(masked, list) and all(isinstance(item, str) for item in masked) @@ -639,7 +641,9 @@ def _unknown_trust_manifest( ) -def _source_key(source: dict[str, Any]) -> tuple[str, str] | None: +def _source_key(source: Any) -> tuple[str, str] | None: + if not isinstance(source, dict): + return None kind = str(source.get("kind", "")) reference = source.get("reference") if not isinstance(reference, str): diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index ee8cfce..4199cf8 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -126,6 +126,38 @@ def time_out(*args: object, **kwargs: object) -> subprocess.CompletedProcess[byt assert "Traceback" not in result.output +def test_cli_invalid_declaration_yaml_is_a_structured_inspection_block( + tmp_path: Path, +) -> None: + repo = _repo(tmp_path) + declaration = tmp_path / "declaration.yaml" + declaration.write_text("name: [unterminated\n", encoding="utf-8") + + result = CliRunner().invoke( + main, + [ + "inspect", + "--repo", + str(repo), + "--declaration", + str(declaration), + "--output", + str(tmp_path / "capsule"), + "--", + "node", + "-e", + "process.exit(0)", + ], + ) + + assert result.exit_code == 2 + payload = json.loads(result.output) + assert payload["ok"] is False + assert payload["error"]["code"] == "inspection_blocked" + assert "while parsing a flow sequence" in payload["error"]["message"] + assert "Traceback" not in result.output + + def test_cleanup_readback_fails_closed_for_nonzero_docker_and_remaining_local_root( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -605,6 +637,7 @@ def test_ignored_untracked_trust_inputs_cannot_escape_commit_binding(tmp_path: P [ ("src/mcp_trust/catalog_snapshot.json", []), ("src/mcp_trust/catalog/seed_servers.json", {"servers": {}}), + ("src/mcp_trust/catalog/seed_servers.json", [{"source": "not-an-object"}]), ("masked-grades.json", {}), ("src/mcp_trust/core/spec_shift_verdicts.json", []), ], From c2d4d957aef63d306808a60d7b83bbaeb0783753 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 04:41:42 -0700 Subject: [PATCH 16/41] Bind trust evidence to actual config pointers --- src/mcp_audit/proof_trust.py | 5 +++-- tests/test_proof_before_action.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/mcp_audit/proof_trust.py b/src/mcp_audit/proof_trust.py index a43d794..02882dd 100644 --- a/src/mcp_audit/proof_trust.py +++ b/src/mcp_audit/proof_trust.py @@ -89,7 +89,8 @@ def discover_repo_mcp( ) ) continue - servers = payload.get("mcpServers", payload.get("servers")) if isinstance(payload, dict) else None + server_map_key = "mcpServers" if isinstance(payload, dict) and "mcpServers" in payload else "servers" + servers = payload.get(server_map_key) if isinstance(payload, dict) else None if not isinstance(servers, dict): diagnostics.append( DiscoveryDiagnostic( @@ -101,7 +102,7 @@ def discover_repo_mcp( ) continue for name, config in servers.items(): - pointer = f"/mcpServers/{_json_pointer(str(name))}" + pointer = f"/{server_map_key}/{_json_pointer(str(name))}" if not isinstance(config, dict): diagnostics.append( DiscoveryDiagnostic( diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index 4199cf8..f487c2d 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import json import shutil import sqlite3 @@ -376,6 +377,34 @@ def test_server_descriptor_scalar_transport_is_a_partial_diagnostic( ] +def test_discovery_preserves_the_selected_server_map_pointer( + tmp_path: Path, +) -> None: + repo = _repo(tmp_path) + (repo / ".mcp.json").write_text( + json.dumps( + { + "servers": { + "known": { + "command": "npx", + "args": ["@fixture/known-mcp"], + }, + "broken": None, + } + } + ), + encoding="utf-8", + ) + + manifest = build_release_trust_manifest(repo, None) + + dependency = manifest.dependencies[0] + assert dependency.source_pointer == "/servers/known" + material = b".mcp.json\0/servers/known\0npm\0@fixture/known-mcp" + assert dependency.dependency_id == "dep_" + hashlib.sha256(material).hexdigest()[:20] + assert manifest.diagnostics[0].source_pointer == "/servers/broken" + + @requires_docker def test_loopback_network_attempt_is_detected_without_external_contact(tmp_path: Path) -> None: repo = _repo(tmp_path) From 16727edd1bddcce32a4103fc22d209fcba4413cf Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 04:50:05 -0700 Subject: [PATCH 17/41] Keep ignored subject inputs unbound --- CHANGELOG.md | 3 ++- docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md | 3 +++ docs/PROOF-BEFORE-ACTION.md | 4 ++- src/mcp_audit/proof_observer.py | 7 ++++- src/mcp_audit/proof_trust.py | 34 +++++++++++++++++++++--- tests/test_proof_before_action.py | 32 ++++++++++++++++++++++ 6 files changed, 77 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fd8f13..dbedc01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `authority: unverified`. New observations report the environment-neutral `docker` provider instead of assuming Colima, and valid-but-wrong-shaped trust inputs become structured - UNKNOWN manifests rather than tracebacks. + UNKNOWN manifests rather than tracebacks. Git-ignored subject files that enter + the staged observation now mark the subject commit dirty and non-binding. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md index a8c8d81..228a9e0 100644 --- a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md +++ b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md @@ -59,6 +59,9 @@ platform. root instead of accepting an arbitrary ancestor repository. - Every required mcp-trust input is read back from the recorded trust commit and compared byte-for-byte before grade details can remain authoritative. +- Git-ignored subject files that enter the observer staging inventory force the + subject repository to dirty/unbound; ignored dependency caches and generated + metadata excluded from staging do not alter subject provenance. ## Residual threats and honest unknowns diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index 94a06d5..99231b2 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -82,7 +82,9 @@ The observer: 1. copies a bounded, symlink-free, UTF-8 text snapshot plus explicitly named synthetic SQLite fixtures into a temporary staging image without `.git`, dependency caches, build output, known secret files, or detected literal - credentials; + credentials; Git-ignored files that still pass these staging filters are + copied but force `repository_dirty: true`, so the recorded subject commit is + explicitly non-binding; 2. creates a container with no host mount, no forwarded socket, network mode `none`, a read-only image root, `no-new-privileges`, and bounded CPU, memory, process, and tmpfs resources; diff --git a/src/mcp_audit/proof_observer.py b/src/mcp_audit/proof_observer.py index 12aaa5b..ed07de1 100644 --- a/src/mcp_audit/proof_observer.py +++ b/src/mcp_audit/proof_observer.py @@ -401,7 +401,7 @@ def _stage_repository(source: Path, destination: Path) -> None: total_bytes = 0 for path in sorted(source.rglob("*")): relative = path.relative_to(source) - if any(part in _IGNORED_NAMES for part in relative.parts): + if not repository_input_is_in_scope(relative): continue if path.is_symlink(): raise ObservationBlocked(f"input contains a symlink: {relative.as_posix()}") @@ -424,6 +424,11 @@ def _stage_repository(source: Path, destination: Path) -> None: shutil.copyfile(path, target) +def repository_input_is_in_scope(relative: Path) -> bool: + """Return whether the observer copies this repository-relative path.""" + return not any(part in _IGNORED_NAMES for part in relative.parts) + + def _make_disposable_writable(root: Path) -> None: for path in root.rglob("*"): os.chmod(path, 0o777 if path.is_dir() else 0o666) diff --git a/src/mcp_audit/proof_trust.py b/src/mcp_audit/proof_trust.py index 02882dd..33b2c83 100644 --- a/src/mcp_audit/proof_trust.py +++ b/src/mcp_audit/proof_trust.py @@ -23,6 +23,7 @@ canonical_json_bytes, sha256_bytes, ) +from mcp_audit.proof_observer import repository_input_is_in_scope _REPO_CONFIGS = (".mcp.json", ".vscode/mcp.json", ".cursor/mcp.json") _EXACT_VERSION = re.compile(r"^\d+(?:\.\d+)*(?:[-+][0-9A-Za-z.-]+)?$") @@ -32,7 +33,7 @@ def build_release_trust_manifest(repo: Path, trust_root: Path | None) -> ReleaseTrustManifest: root = repo.resolve() dependencies, diagnostics = discover_repo_mcp(root) - commit, dirty = _git_state(root) + commit, dirty = _git_state(root, include_staged_ignored=True) if trust_root is None: entries = [ TrustEntry( @@ -737,7 +738,11 @@ def _is_stale(scanned_at: Any, evaluated_at: str) -> bool | None: return (evaluated - scanned).days > 90 -def _git_state(root: Path) -> tuple[str | None, bool | None]: +def _git_state( + root: Path, + *, + include_staged_ignored: bool = False, +) -> tuple[str | None, bool | None]: try: commit = subprocess.run( ["git", "-C", str(root), "rev-parse", "HEAD"], @@ -753,7 +758,30 @@ def _git_state(root: Path) -> tuple[str | None, bool | None]: text=True, timeout=5, ).stdout - return commit, bool(status) + dirty = bool(status) + if include_staged_ignored: + ignored = subprocess.run( + [ + "git", + "-C", + str(root), + "ls-files", + "--others", + "--ignored", + "--exclude-standard", + "-z", + "--", + ".", + ], + check=True, + capture_output=True, + text=True, + timeout=5, + ).stdout + dirty = dirty or any( + repository_input_is_in_scope(Path(relative)) for relative in ignored.split("\0") if relative + ) + return commit, dirty except (OSError, subprocess.SubprocessError): return None, None diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index f487c2d..e38b81f 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -405,6 +405,38 @@ def test_discovery_preserves_the_selected_server_map_pointer( assert manifest.diagnostics[0].source_pointer == "/servers/broken" +def test_ignored_staged_subject_input_marks_the_commit_unbound( + tmp_path: Path, +) -> None: + repo = _repo(tmp_path) + (repo / ".gitignore").write_text(".mcp.json\nnode_modules/\n", encoding="utf-8") + subprocess.run(["git", "init", "-q", str(repo)], check=True) + subprocess.run(["git", "-C", str(repo), "config", "user.email", "proof@example.test"], check=True) + subprocess.run(["git", "-C", str(repo), "config", "user.name", "Proof Fixture"], check=True) + subprocess.run(["git", "-C", str(repo), "add", "."], check=True) + subprocess.run(["git", "-C", str(repo), "commit", "-qm", "fixture"], check=True) + (repo / "node_modules").mkdir() + (repo / "node_modules/cache.txt").write_text("excluded\n", encoding="utf-8") + + excluded_only = build_release_trust_manifest(repo, None) + + assert excluded_only.repository_dirty is False + (repo / ".mcp.json").write_text( + '{"mcpServers":{"ignored":{"command":"npx","args":["@fixture/ignored-mcp"]}}}', + encoding="utf-8", + ) + + manifest = build_release_trust_manifest(repo, None) + + assert manifest.repository_commit == excluded_only.repository_commit + assert manifest.repository_dirty is True + assert manifest.dependencies[0].source_path == ".mcp.json" + assert ( + "Subject repository is dirty; its commit does not bind the inspected working tree." + in manifest.limitations + ) + + @requires_docker def test_loopback_network_attempt_is_detected_without_external_contact(tmp_path: Path) -> None: repo = _repo(tmp_path) From c20d47fd092b004c7c6f194d6cc3dc8b28ba8088 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 05:05:15 -0700 Subject: [PATCH 18/41] Bind evidence to the staged subject snapshot --- CHANGELOG.md | 3 + docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md | 3 + docs/PROOF-BEFORE-ACTION.md | 12 ++- src/mcp_audit/proof_capsule.py | 9 ++ src/mcp_audit/proof_cli.py | 6 +- src/mcp_audit/proof_models.py | 37 +++++--- src/mcp_audit/proof_observer.py | 87 +++++++++++++++++- src/mcp_audit/proof_trust.py | 92 ++++++++++--------- tests/test_proof_before_action.py | 108 ++++++++++++++++++++--- 9 files changed, 282 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbedc01..e5d3836 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 assuming Colima, and valid-but-wrong-shaped trust inputs become structured UNKNOWN manifests rather than tracebacks. Git-ignored subject files that enter the staged observation now mark the subject commit dirty and non-binding. + Subject commit binding and dependency discovery now travel with the exact + pre-execution staged-tree hash, and trust parsing/hashing/commit comparison + uses one captured byte snapshot to prevent clean-after-read races. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md index 228a9e0..298ea29 100644 --- a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md +++ b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md @@ -62,6 +62,9 @@ platform. - Git-ignored subject files that enter the observer staging inventory force the subject repository to dirty/unbound; ignored dependency caches and generated metadata excluded from staging do not alter subject provenance. +- Subject commit binding, staged-tree hashing, and dependency discovery all use + the same pre-execution staged snapshot. Trust joining likewise parses, hashes, + and commit-compares one captured byte set rather than rereading live files. ## Residual threats and honest unknowns diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index 99231b2..276ab16 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -84,7 +84,9 @@ The observer: dependency caches, build output, known secret files, or detected literal credentials; Git-ignored files that still pass these staging filters are copied but force `repository_dirty: true`, so the recorded subject commit is - explicitly non-binding; + explicitly non-binding. Dependency discovery, a staged-tree hash, and the + byte comparison with the recorded subject commit are captured from this same + immutable staged copy before execution; 2. creates a container with no host mount, no forwarded socket, network mode `none`, a read-only image root, `no-new-privileges`, and bounded CPU, memory, process, and tmpfs resources; @@ -114,15 +116,17 @@ it is hashed and omitted. ## Release trust manifest -Repository-only discovery covers `.mcp.json`, `.vscode/mcp.json`, +Repository-only discovery runs against the exact staged subject snapshot and +covers `.mcp.json`, `.vscode/mcp.json`, `.cursor/mcp.json`, MCP-named `package.json` and `pyproject.toml` dependencies, and `server.json` packages. Every occurrence gets a stable dependency ID and exact source pointer. Environment and header values are never copied; only key names are retained. -The join uses the local mcp-trust catalog snapshot, catalog seed, +The join uses one byte snapshot of the local mcp-trust catalog snapshot, catalog seed, `masked-grades.json`, and spec-shift format version. Every required input must be -tracked and byte-identical to the recorded trust commit; ignored or otherwise +tracked, and the bytes actually parsed and hashed must be byte-identical to the +recorded trust commit; ignored or otherwise untracked files do not count as clean authority. Missing, stale, masked, ambiguous, unmatched, dirty-source, commit-unbound, or version-unbound evidence remains explicit in the manifest. A grade is historical evidence about an diff --git a/src/mcp_audit/proof_capsule.py b/src/mcp_audit/proof_capsule.py index ddedc6e..0248b15 100644 --- a/src/mcp_audit/proof_capsule.py +++ b/src/mcp_audit/proof_capsule.py @@ -174,6 +174,15 @@ def build_capsule( comparison: BillComparison, trust_manifest: ReleaseTrustManifest, ) -> EvidenceCapsule: + subject = observation.subject_snapshot + if ( + trust_manifest.repository_commit != subject.repository_commit + or trust_manifest.repository_dirty != subject.repository_dirty + or trust_manifest.repository_staged_tree_sha256 != subject.staged_tree_sha256 + or trust_manifest.dependencies != subject.dependencies + or trust_manifest.diagnostics != subject.diagnostics + ): + raise ValueError("trust manifest subject evidence does not match the staged observation snapshot") commit, dirty, provenance_source = _producer_state() producer_limitations: list[str] = [] if commit is None: diff --git a/src/mcp_audit/proof_cli.py b/src/mcp_audit/proof_cli.py index c12f48b..bf793fb 100644 --- a/src/mcp_audit/proof_cli.py +++ b/src/mcp_audit/proof_cli.py @@ -70,7 +70,11 @@ def inspect( timeout_seconds=timeout_seconds, ) comparison = compare_bill(declared, observed) - trust = build_release_trust_manifest(repo, trust_root) + trust = build_release_trust_manifest( + repo, + trust_root, + subject_snapshot=observed.subject_snapshot, + ) capsule = build_capsule(declared, observed, comparison, trust) root_sha256 = export_capsule(capsule, output) except (OSError, ValueError, yaml.YAMLError, ValidationError, ObservationBlocked) as exc: diff --git a/src/mcp_audit/proof_models.py b/src/mcp_audit/proof_models.py index 52daa91..02d9c88 100644 --- a/src/mcp_audit/proof_models.py +++ b/src/mcp_audit/proof_models.py @@ -150,18 +150,6 @@ class NetworkEvidence(StrictModel): external_contact_count: Literal[0] = 0 -class Observation(StrictModel): - schema_version: Literal["proof-before-action.observation.v1"] = OBSERVATION_SCHEMA - isolation: IsolationEvidence - command: CommandEvidence - filesystem: SurfaceObservation - file_changes: list[FileChange] = Field(default_factory=list) - database: SurfaceObservation - database_changes: list[DatabaseChange] = Field(default_factory=list) - network: NetworkEvidence - limitations: list[str] = Field(default_factory=list) - - class ComparisonFinding(StrictModel): code: str severity: Literal["error", "unknown", "info"] @@ -201,6 +189,27 @@ class DiscoveryDiagnostic(StrictModel): message: str +class SubjectSnapshotEvidence(StrictModel): + repository_commit: str | None + repository_dirty: bool | None + staged_tree_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + dependencies: list[DependencyOccurrence] = Field(default_factory=list) + diagnostics: list[DiscoveryDiagnostic] = Field(default_factory=list) + + +class Observation(StrictModel): + schema_version: Literal["proof-before-action.observation.v1"] = OBSERVATION_SCHEMA + subject_snapshot: SubjectSnapshotEvidence + isolation: IsolationEvidence + command: CommandEvidence + filesystem: SurfaceObservation + file_changes: list[FileChange] = Field(default_factory=list) + database: SurfaceObservation + database_changes: list[DatabaseChange] = Field(default_factory=list) + network: NetworkEvidence + limitations: list[str] = Field(default_factory=list) + + class TrustEvidence(StrictModel): state: Literal[ "current", @@ -256,6 +265,10 @@ class ReleaseTrustManifest(StrictModel): schema_version: Literal["proof-before-action.trust-manifest.v1"] = TRUST_MANIFEST_SCHEMA repository_commit: str | None repository_dirty: bool | None + repository_staged_tree_sha256: str | None = Field( + default=None, + pattern=r"^[0-9a-f]{64}$", + ) discovery_coverage: Literal["complete", "partial", "unknown"] dependencies: list[DependencyOccurrence] diagnostics: list[DiscoveryDiagnostic] = Field(default_factory=list) diff --git a/src/mcp_audit/proof_observer.py b/src/mcp_audit/proof_observer.py index ed07de1..a61d865 100644 --- a/src/mcp_audit/proof_observer.py +++ b/src/mcp_audit/proof_observer.py @@ -26,6 +26,7 @@ IsolationEvidence, NetworkEvidence, Observation, + SubjectSnapshotEvidence, SurfaceObservation, canonical_json_bytes, sha256_bytes, @@ -185,8 +186,10 @@ def observe_command( staging_container_id: str | None = None runtime_image: str | None = None try: - _stage_repository(repo.resolve(), staged) + subject_root = repo.resolve() + _stage_repository(subject_root, staged) before_files = _file_snapshot(staged) + subject_snapshot = _subject_snapshot_evidence(subject_root, staged, before_files) before_databases = _database_snapshot(staged) _make_disposable_writable(staged) image_id = _local_image_id(image) @@ -348,6 +351,7 @@ def observe_command( ) recorded_argv, recorded_argv_sha256 = _command_argv_evidence(command) return Observation( + subject_snapshot=subject_snapshot, isolation=isolation, command=CommandEvidence( argv=recorded_argv, @@ -429,6 +433,87 @@ def repository_input_is_in_scope(relative: Path) -> bool: return not any(part in _IGNORED_NAMES for part in relative.parts) +def _subject_snapshot_evidence( + source: Path, + staged: Path, + staged_tree: dict[str, tuple[str, str | None]], +) -> SubjectSnapshotEvidence: + from mcp_audit.proof_trust import discover_repo_mcp + + dependencies, diagnostics = discover_repo_mcp(staged) + staged_tree_sha256 = sha256_bytes(canonical_json_bytes(staged_tree)) + commit: str | None = None + dirty: bool | None = None + try: + commit = subprocess.run( + ["git", "-C", str(source), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + timeout=5, + ).stdout.strip() + prefix = subprocess.run( + ["git", "-C", str(source), "rev-parse", "--show-prefix"], + check=True, + capture_output=True, + text=True, + timeout=5, + ).stdout.strip() + object_format = subprocess.run( + ["git", "-C", str(source), "rev-parse", "--show-object-format"], + check=True, + capture_output=True, + text=True, + timeout=5, + ).stdout.strip() + tree_command = ["git", "-C", str(source), "ls-tree", "-r", "-z", commit] + if prefix: + tree_command.extend(["--", prefix]) + tree = subprocess.run( + tree_command, + check=True, + capture_output=True, + timeout=5, + ).stdout + committed_tree: dict[str, tuple[str, str | None]] = {} + for record in tree.split(b"\0"): + if not record: + continue + metadata, raw_path = record.split(b"\t", 1) + mode, object_type, object_id = metadata.decode("ascii").split() + repository_path = os.fsdecode(raw_path) + relative = repository_path[len(prefix) :] if prefix else repository_path + if repository_input_is_in_scope(Path(relative)): + kind = "file" if object_type == "blob" and mode in {"100644", "100755"} else "other" + committed_tree[relative] = (kind, object_id if kind == "file" else None) + parent = Path(relative).parent + while parent != Path("."): + committed_tree[parent.as_posix()] = ("directory", None) + parent = parent.parent + dirty = set(staged_tree) != set(committed_tree) or any( + staged_tree[path][0] != committed_tree[path][0] for path in set(staged_tree) & set(committed_tree) + ) + if not dirty: + for relative, (kind, expected_object_id) in committed_tree.items(): + if kind != "file": + continue + value = (staged / relative).read_bytes() + header = f"blob {len(value)}\0".encode() + actual_object_id = hashlib.new(object_format, header + value).hexdigest() + if expected_object_id != actual_object_id: + dirty = True + break + except (OSError, UnicodeError, ValueError, subprocess.SubprocessError): + dirty = None + return SubjectSnapshotEvidence( + repository_commit=commit, + repository_dirty=dirty, + staged_tree_sha256=staged_tree_sha256, + dependencies=dependencies, + diagnostics=diagnostics, + ) + + def _make_disposable_writable(root: Path) -> None: for path in root.rglob("*"): os.chmod(path, 0o777 if path.is_dir() else 0o666) diff --git a/src/mcp_audit/proof_trust.py b/src/mcp_audit/proof_trust.py index 33b2c83..b1821eb 100644 --- a/src/mcp_audit/proof_trust.py +++ b/src/mcp_audit/proof_trust.py @@ -17,23 +17,36 @@ DependencyOccurrence, DiscoveryDiagnostic, ReleaseTrustManifest, + SubjectSnapshotEvidence, TrustEntry, TrustEvidence, TrustSource, canonical_json_bytes, sha256_bytes, ) -from mcp_audit.proof_observer import repository_input_is_in_scope _REPO_CONFIGS = (".mcp.json", ".vscode/mcp.json", ".cursor/mcp.json") _EXACT_VERSION = re.compile(r"^\d+(?:\.\d+)*(?:[-+][0-9A-Za-z.-]+)?$") _PYPI_NORMALIZE = re.compile(r"[-_.]+") -def build_release_trust_manifest(repo: Path, trust_root: Path | None) -> ReleaseTrustManifest: +def build_release_trust_manifest( + repo: Path, + trust_root: Path | None, + *, + subject_snapshot: SubjectSnapshotEvidence | None = None, +) -> ReleaseTrustManifest: root = repo.resolve() - dependencies, diagnostics = discover_repo_mcp(root) - commit, dirty = _git_state(root, include_staged_ignored=True) + if subject_snapshot is None: + dependencies, diagnostics = discover_repo_mcp(root) + commit, dirty = _git_state(root) + staged_tree_sha256 = None + else: + dependencies = subject_snapshot.dependencies + diagnostics = subject_snapshot.diagnostics + commit = subject_snapshot.repository_commit + dirty = subject_snapshot.repository_dirty + staged_tree_sha256 = subject_snapshot.staged_tree_sha256 if trust_root is None: entries = [ TrustEntry( @@ -49,6 +62,7 @@ def build_release_trust_manifest(repo: Path, trust_root: Path | None) -> Release return ReleaseTrustManifest( repository_commit=commit, repository_dirty=dirty, + repository_staged_tree_sha256=staged_tree_sha256, discovery_coverage="partial" if diagnostics else "complete", dependencies=dependencies, diagnostics=diagnostics, @@ -60,12 +74,12 @@ def build_release_trust_manifest(repo: Path, trust_root: Path | None) -> Release ], ) return _join_trust( - root, trust_root.resolve(), dependencies, diagnostics, repository_commit=commit, repository_dirty=dirty, + repository_staged_tree_sha256=staged_tree_sha256, ) @@ -341,13 +355,13 @@ def _occurrence( def _join_trust( - repo: Path, trust_root: Path, dependencies: list[DependencyOccurrence], diagnostics: list[DiscoveryDiagnostic], *, repository_commit: str | None, repository_dirty: bool | None, + repository_staged_tree_sha256: str | None, ) -> ReleaseTrustManifest: files = { "catalog_snapshot.json": trust_root / "src/mcp_trust/catalog_snapshot.json", @@ -371,19 +385,22 @@ def _join_trust( diagnostics, repository_commit, repository_dirty, + repository_staged_tree_sha256, f"mcp-trust source is incomplete: {', '.join(sorted(missing))}", ) try: - snapshot = json.loads(files["catalog_snapshot.json"].read_text(encoding="utf-8")) - seed = json.loads(files["seed_servers.json"].read_text(encoding="utf-8")) - masked_payload = json.loads(files["masked-grades.json"].read_text(encoding="utf-8")) - spec_shift = json.loads(files["spec_shift_verdicts.json"].read_text(encoding="utf-8")) + input_bytes = {name: path.read_bytes() for name, path in files.items()} + snapshot = json.loads(input_bytes["catalog_snapshot.json"]) + seed = json.loads(input_bytes["seed_servers.json"]) + masked_payload = json.loads(input_bytes["masked-grades.json"]) + spec_shift = json.loads(input_bytes["spec_shift_verdicts.json"]) except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: return _unknown_trust_manifest( dependencies, diagnostics, repository_commit, repository_dirty, + repository_staged_tree_sha256, f"mcp-trust source could not be parsed: {type(exc).__name__}", ) if not _valid_trust_input_shapes(snapshot, seed, masked_payload, spec_shift): @@ -392,16 +409,17 @@ def _join_trust( diagnostics, repository_commit, repository_dirty, + repository_staged_tree_sha256, "mcp-trust source has an unsupported data shape", ) masked = set(masked_payload) trust_commit, trust_dirty = _git_state(trust_root) trust_inputs_bound = bool( trust_commit - and _files_match_git_commit( + and _input_bytes_match_git_commit( trust_root, trust_commit, - list(files.values()), + {files[name]: value for name, value in input_bytes.items()}, ) ) snapshot_generated_at = str(snapshot.get("generated_at", "")) or "unknown" @@ -413,7 +431,7 @@ def _join_trust( "catalog_snapshot": snapshot.get("schema_version", "unknown"), "spec_shift": spec_shift.get("format_version", "unknown"), }, - file_sha256={name: sha256_bytes(path.read_bytes()) for name, path in sorted(files.items())}, + file_sha256={name: sha256_bytes(input_bytes[name]) for name in sorted(files)}, snapshot_generated_at=snapshot_generated_at, evaluated_at=evaluated_at, ) @@ -478,6 +496,7 @@ def _join_trust( return ReleaseTrustManifest( repository_commit=repository_commit, repository_dirty=repository_dirty, + repository_staged_tree_sha256=repository_staged_tree_sha256, discovery_coverage="partial" if diagnostics else "complete", dependencies=dependencies, diagnostics=diagnostics, @@ -619,11 +638,13 @@ def _unknown_trust_manifest( diagnostics: list[DiscoveryDiagnostic], repository_commit: str | None, repository_dirty: bool | None, + repository_staged_tree_sha256: str | None, reason: str, ) -> ReleaseTrustManifest: return ReleaseTrustManifest( repository_commit=repository_commit, repository_dirty=repository_dirty, + repository_staged_tree_sha256=repository_staged_tree_sha256, discovery_coverage="unknown", dependencies=dependencies, diagnostics=diagnostics, @@ -738,11 +759,7 @@ def _is_stale(scanned_at: Any, evaluated_at: str) -> bool | None: return (evaluated - scanned).days > 90 -def _git_state( - root: Path, - *, - include_staged_ignored: bool = False, -) -> tuple[str | None, bool | None]: +def _git_state(root: Path) -> tuple[str | None, bool | None]: try: commit = subprocess.run( ["git", "-C", str(root), "rev-parse", "HEAD"], @@ -758,40 +775,17 @@ def _git_state( text=True, timeout=5, ).stdout - dirty = bool(status) - if include_staged_ignored: - ignored = subprocess.run( - [ - "git", - "-C", - str(root), - "ls-files", - "--others", - "--ignored", - "--exclude-standard", - "-z", - "--", - ".", - ], - check=True, - capture_output=True, - text=True, - timeout=5, - ).stdout - dirty = dirty or any( - repository_input_is_in_scope(Path(relative)) for relative in ignored.split("\0") if relative - ) - return commit, dirty + return commit, bool(status) except (OSError, subprocess.SubprocessError): return None, None -def _files_match_git_commit( +def _input_bytes_match_git_commit( root: Path, commit: str, - paths: list[Path], + inputs: dict[Path, bytes], ) -> bool: - for path in paths: + for path, loaded_bytes in inputs.items(): try: relative = path.relative_to(root).as_posix() committed = subprocess.run( @@ -800,7 +794,7 @@ def _files_match_git_commit( capture_output=True, timeout=5, ) - if committed.returncode != 0 or committed.stdout != path.read_bytes(): + if committed.returncode != 0 or committed.stdout != loaded_bytes: return False except (OSError, ValueError, subprocess.SubprocessError): return False @@ -811,7 +805,11 @@ def _repository_limitations(commit: str | None, dirty: bool | None) -> list[str] limitations: list[str] = [] if commit is None: limitations.append("Subject repository commit is UNKNOWN; release evidence is not commit-bound.") - if dirty: + if dirty is None: + limitations.append( + "Subject staged-tree binding is UNKNOWN; the repository commit cannot be treated as binding." + ) + elif dirty: limitations.append( "Subject repository is dirty; its commit does not bind the inspected working tree." ) diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index e38b81f..81e5177 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -23,7 +23,9 @@ from mcp_audit.proof_models import ( CAPSULE_SCHEMA, ActionDeclaration, + Observation, ReleaseTrustManifest, + SubjectSnapshotEvidence, canonical_json_bytes, sha256_bytes, ) @@ -32,7 +34,10 @@ _cleanup_docker_resource, _cleanup_local_root, _command_argv_evidence, + _file_snapshot, _redact_argv, + _stage_repository, + _subject_snapshot_evidence, observe_command, ) from mcp_audit.proof_trust import build_release_trust_manifest @@ -72,8 +77,12 @@ def _repo(tmp_path: Path) -> Path: return root -def _empty_trust(repo: Path) -> ReleaseTrustManifest: - return build_release_trust_manifest(repo, None) +def _empty_trust(repo: Path, observation: Observation) -> ReleaseTrustManifest: + return build_release_trust_manifest( + repo, + None, + subject_snapshot=observation.subject_snapshot, + ) def test_cli_docker_timeout_is_a_structured_inspection_block( @@ -192,8 +201,8 @@ def test_read_only_command_passes_and_is_deterministic(tmp_path: Path) -> None: first_comparison = compare_bill(_declaration(), first) second_comparison = compare_bill(_declaration(), second) assert first_comparison.verdict == "pass" - first_capsule = build_capsule(_declaration(), first, first_comparison, _empty_trust(repo)) - second_capsule = build_capsule(_declaration(), second, second_comparison, _empty_trust(repo)) + first_capsule = build_capsule(_declaration(), first, first_comparison, _empty_trust(repo, first)) + second_capsule = build_capsule(_declaration(), second, second_comparison, _empty_trust(repo, second)) assert canonical_json_bytes(first_capsule) == canonical_json_bytes(second_capsule) @@ -417,19 +426,40 @@ def test_ignored_staged_subject_input_marks_the_commit_unbound( subprocess.run(["git", "-C", str(repo), "commit", "-qm", "fixture"], check=True) (repo / "node_modules").mkdir() (repo / "node_modules/cache.txt").write_text("excluded\n", encoding="utf-8") + excluded_stage = tmp_path / "excluded-stage" + excluded_stage.mkdir() + _stage_repository(repo, excluded_stage) - excluded_only = build_release_trust_manifest(repo, None) + excluded_only = _subject_snapshot_evidence( + repo, + excluded_stage, + _file_snapshot(excluded_stage), + ) assert excluded_only.repository_dirty is False (repo / ".mcp.json").write_text( '{"mcpServers":{"ignored":{"command":"npx","args":["@fixture/ignored-mcp"]}}}', encoding="utf-8", ) + ignored_stage = tmp_path / "ignored-stage" + ignored_stage.mkdir() + _stage_repository(repo, ignored_stage) + subject_snapshot = _subject_snapshot_evidence( + repo, + ignored_stage, + _file_snapshot(ignored_stage), + ) + (repo / ".mcp.json").unlink() - manifest = build_release_trust_manifest(repo, None) + manifest = build_release_trust_manifest( + repo, + None, + subject_snapshot=subject_snapshot, + ) - assert manifest.repository_commit == excluded_only.repository_commit + assert manifest.repository_commit == subject_snapshot.repository_commit assert manifest.repository_dirty is True + assert manifest.repository_staged_tree_sha256 == subject_snapshot.staged_tree_sha256 assert manifest.dependencies[0].source_path == ".mcp.json" assert ( "Subject repository is dirty; its commit does not bind the inspected working tree." @@ -465,7 +495,6 @@ def test_declaration_omission_is_deterministic() -> None: FileChange, IsolationEvidence, NetworkEvidence, - Observation, SurfaceObservation, ) @@ -478,6 +507,11 @@ def test_declaration_omission_is_deterministic() -> None: complete=True, ) observation = Observation( + subject_snapshot=SubjectSnapshotEvidence( + repository_commit=None, + repository_dirty=None, + staged_tree_sha256="d" * 64, + ), isolation=IsolationEvidence( image_reference="fixture", image_id="sha256:" + "a" * 64, @@ -660,6 +694,42 @@ def test_dirty_trust_source_cannot_emit_authoritative_grade_details(tmp_path: Pa ) +def test_loaded_trust_bytes_must_match_the_recorded_commit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = _repo(tmp_path) + (repo / ".mcp.json").write_text( + '{"mcpServers":{"known":{"command":"npx","args":["@fixture/known-mcp"]}}}', + encoding="utf-8", + ) + trust = _trust_fixture(tmp_path) + snapshot_path = trust / "src/mcp_trust/catalog_snapshot.json" + original_read_bytes = Path.read_bytes + + def read_bytes(path: Path) -> bytes: + value = original_read_bytes(path) + if path == snapshot_path: + payload = json.loads(value) + payload["servers"][0]["grade"] = "A" + return json.dumps(payload).encode() + return value + + monkeypatch.setattr(Path, "read_bytes", read_bytes) + + manifest = build_release_trust_manifest(repo, trust) + + assert manifest.trust_source is not None + assert manifest.trust_source.dirty is False + evidence = manifest.entries[0].evidence + assert evidence.state == "unverifiable" + assert evidence.grade is None + assert ( + "required mcp-trust inputs are not byte-identical to the trust commit; " + "entry-level trust evidence is non-authoritative" in evidence.unknown_reasons + ) + + def test_ignored_untracked_trust_inputs_cannot_escape_commit_binding(tmp_path: Path) -> None: repo = _repo(tmp_path) (repo / ".mcp.json").write_text( @@ -780,7 +850,16 @@ def test_tampering_and_wrong_commit_or_schema_are_detected(tmp_path: Path) -> No declaration = _declaration() observation = observe_command(repo, ["node", "-e", "process.exit(0)"], image="node:24-slim") comparison = compare_bill(declaration, observation) - capsule = build_capsule(declaration, observation, comparison, build_release_trust_manifest(repo, None)) + capsule = build_capsule( + declaration, + observation, + comparison, + build_release_trust_manifest( + repo, + None, + subject_snapshot=observation.subject_snapshot, + ), + ) output = tmp_path / "capsule" root_sha = export_capsule(capsule, output) assert verify_capsule(output, expect_root_sha256=root_sha)["valid"] is True @@ -821,7 +900,16 @@ def test_offline_html_escapes_untrusted_text(tmp_path: Path) -> None: declaration = _declaration(name="") observation = observe_command(repo, ["node", "-e", "process.exit(0)"], image="node:24-slim") comparison = compare_bill(declaration, observation) - capsule = build_capsule(declaration, observation, comparison, build_release_trust_manifest(repo, None)) + capsule = build_capsule( + declaration, + observation, + comparison, + build_release_trust_manifest( + repo, + None, + subject_snapshot=observation.subject_snapshot, + ), + ) output = tmp_path / "capsule" export_capsule(capsule, output) page = (output / "report.html").read_text(encoding="utf-8") From 04926234bc78c087400d2b90a2dad2204dda9a76 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 05:17:27 -0700 Subject: [PATCH 19/41] Close staging races without breaking v1 --- CHANGELOG.md | 5 +- docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md | 3 + docs/PROOF-BEFORE-ACTION.md | 9 +- src/mcp_audit/proof_capsule.py | 4 +- src/mcp_audit/proof_cli.py | 2 + src/mcp_audit/proof_models.py | 2 +- src/mcp_audit/proof_observer.py | 101 +++++++++++++++++------ tests/test_proof_before_action.py | 72 ++++++++++++++-- 8 files changed, 166 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5d3836..1e9b63b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the staged observation now mark the subject commit dirty and non-binding. Subject commit binding and dependency discovery now travel with the exact pre-execution staged-tree hash, and trust parsing/hashing/commit comparison - uses one captured byte snapshot to prevent clean-after-read races. + uses one captured byte snapshot to prevent clean-after-read races. No-follow, + directory-relative source descriptors close validation-to-copy link races, + while legacy observation-v1 capsules remain verification-compatible and new + capsules require the staged subject binding. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md index 298ea29..3eecb89 100644 --- a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md +++ b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md @@ -65,6 +65,9 @@ platform. - Subject commit binding, staged-tree hashing, and dependency discovery all use the same pre-execution staged snapshot. Trust joining likewise parses, hashes, and commit-compares one captured byte set rather than rereading live files. +- Repository files are copied from no-follow descriptors opened relative to + walked directory descriptors, then the private copied bytes receive content + validation. A source-path replacement cannot redirect the copy. ## Residual threats and honest unknowns diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index 276ab16..085d52c 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -86,7 +86,9 @@ The observer: copied but force `repository_dirty: true`, so the recorded subject commit is explicitly non-binding. Dependency discovery, a staged-tree hash, and the byte comparison with the recorded subject commit are captured from this same - immutable staged copy before execution; + immutable staged copy before execution. Files are opened relative to walked + directory descriptors with link following disabled, copied from that open + identity, and validated again from the private staged bytes; 2. creates a container with no host mount, no forwarded socket, network mode `none`, a read-only image root, `no-new-privileges`, and bounded CPU, memory, process, and tmpfs resources; @@ -156,6 +158,11 @@ JSON semantics, or changing evidence meaning requires a new version identifier. The capsule index is versioned separately so the portable envelope can evolve without silently changing capsule semantics. +Legacy observation-v1 capsules emitted before staged subject evidence was added +remain verifiable. New capsule construction requires `subject_snapshot` and the +matching manifest staged-tree hash; compatibility parsing does not let new +producers omit that binding. + Canonical JSON uses UTF-8, sorted keys, compact separators, one terminal newline, and no floating-point values. The primitive is compatible with AIGCCore's canonical JSON and SHA-256 approach; the source commit is recorded in every diff --git a/src/mcp_audit/proof_capsule.py b/src/mcp_audit/proof_capsule.py index 0248b15..93ac354 100644 --- a/src/mcp_audit/proof_capsule.py +++ b/src/mcp_audit/proof_capsule.py @@ -175,6 +175,8 @@ def build_capsule( trust_manifest: ReleaseTrustManifest, ) -> EvidenceCapsule: subject = observation.subject_snapshot + if subject is None: + raise ValueError("new capsules require staged subject snapshot evidence") if ( trust_manifest.repository_commit != subject.repository_commit or trust_manifest.repository_dirty != subject.repository_dirty @@ -378,7 +380,7 @@ def verify_capsule( errors.append({"code": "capsule_schema_invalid", "message": type(exc).__name__}) capsule = None if capsule is not None: - payload_digest = sha256_bytes(canonical_json_bytes(capsule.payload)) + payload_digest = sha256_bytes(canonical_json_bytes(raw["payload"])) if payload_digest != capsule.integrity.payload_sha256: errors.append({"code": "payload_tampered", "message": "payload hash mismatch"}) if expect_schema and capsule.schema_version != expect_schema: diff --git a/src/mcp_audit/proof_cli.py b/src/mcp_audit/proof_cli.py index bf793fb..4310c2b 100644 --- a/src/mcp_audit/proof_cli.py +++ b/src/mcp_audit/proof_cli.py @@ -69,6 +69,8 @@ def inspect( image=image, timeout_seconds=timeout_seconds, ) + if observed.subject_snapshot is None: + raise ObservationBlocked("staged subject snapshot evidence was not captured") comparison = compare_bill(declared, observed) trust = build_release_trust_manifest( repo, diff --git a/src/mcp_audit/proof_models.py b/src/mcp_audit/proof_models.py index 02d9c88..426ad79 100644 --- a/src/mcp_audit/proof_models.py +++ b/src/mcp_audit/proof_models.py @@ -199,7 +199,7 @@ class SubjectSnapshotEvidence(StrictModel): class Observation(StrictModel): schema_version: Literal["proof-before-action.observation.v1"] = OBSERVATION_SCHEMA - subject_snapshot: SubjectSnapshotEvidence + subject_snapshot: SubjectSnapshotEvidence | None = None isolation: IsolationEvidence command: CommandEvidence filesystem: SurfaceObservation diff --git a/src/mcp_audit/proof_observer.py b/src/mcp_audit/proof_observer.py index a61d865..6aed7b9 100644 --- a/src/mcp_audit/proof_observer.py +++ b/src/mcp_audit/proof_observer.py @@ -401,31 +401,70 @@ def observe_command( def _stage_repository(source: Path, destination: Path) -> None: if not source.is_dir(): raise ObservationBlocked("repository path is not a directory") + no_follow = getattr(os, "O_NOFOLLOW", None) + if no_follow is None: + raise ObservationBlocked("platform cannot securely open repository inputs without following links") file_count = 0 total_bytes = 0 - for path in sorted(source.rglob("*")): - relative = path.relative_to(source) - if not repository_input_is_in_scope(relative): - continue - if path.is_symlink(): - raise ObservationBlocked(f"input contains a symlink: {relative.as_posix()}") - target = destination / relative - if path.is_dir(): - target.mkdir(parents=True, exist_ok=True) - continue - if not path.is_file(): - raise ObservationBlocked(f"unsupported input file type: {relative.as_posix()}") - if path.name.lower() in _SENSITIVE_INPUT_NAMES: - raise ObservationBlocked( - f"repository contains a sensitive file that will not be copied: {relative.as_posix()}" - ) - file_count += 1 - total_bytes += path.stat().st_size - if file_count > _MAX_FILES or total_bytes > _MAX_INPUT_BYTES: - raise ObservationBlocked("repository exceeds the staging file-count or byte limit") - _validate_staged_input(path, relative) - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(path, target) + for directory, directory_names, file_names, directory_fd in os.fwalk( + source, + topdown=True, + follow_symlinks=False, + ): + relative_directory = Path(directory).relative_to(source) + retained_directories: list[str] = [] + for name in sorted(directory_names): + relative = relative_directory / name + if not repository_input_is_in_scope(relative): + continue + try: + mode = os.stat(name, dir_fd=directory_fd, follow_symlinks=False).st_mode + except OSError as exc: + raise ObservationBlocked( + f"repository input directory could not be inspected: {relative.as_posix()}" + ) from exc + if not stat.S_ISDIR(mode): + raise ObservationBlocked(f"input contains a symlink: {relative.as_posix()}") + retained_directories.append(name) + (destination / relative).mkdir(parents=True, exist_ok=True) + directory_names[:] = retained_directories + + for name in sorted(file_names): + relative = relative_directory / name + if not repository_input_is_in_scope(relative): + continue + if name.lower() in _SENSITIVE_INPUT_NAMES: + raise ObservationBlocked( + f"repository contains a sensitive file that will not be copied: {relative.as_posix()}" + ) + flags = os.O_RDONLY | no_follow | getattr(os, "O_CLOEXEC", 0) | os.O_NONBLOCK + try: + descriptor = os.open(name, flags, dir_fd=directory_fd) + except OSError as exc: + raise ObservationBlocked( + f"input contains a symlink or unreadable file: {relative.as_posix()}" + ) from exc + try: + opened = os.fstat(descriptor) + if not stat.S_ISREG(opened.st_mode): + raise ObservationBlocked(f"unsupported input file type: {relative.as_posix()}") + file_count += 1 + if file_count > _MAX_FILES or opened.st_size > _MAX_INPUT_BYTES - total_bytes: + raise ObservationBlocked("repository exceeds the staging file-count or byte limit") + target = destination / relative + target.parent.mkdir(parents=True, exist_ok=True) + with os.fdopen(descriptor, "rb") as source_file: + descriptor = -1 + copied_bytes = _copy_open_repository_file( + source_file, + target, + max_bytes=_MAX_INPUT_BYTES - total_bytes, + ) + total_bytes += copied_bytes + _validate_staged_input(target, relative) + finally: + if descriptor >= 0: + os.close(descriptor) def repository_input_is_in_scope(relative: Path) -> bool: @@ -433,6 +472,22 @@ def repository_input_is_in_scope(relative: Path) -> bool: return not any(part in _IGNORED_NAMES for part in relative.parts) +def _copy_open_repository_file( + source: Any, + target: Path, + *, + max_bytes: int, +) -> int: + copied = 0 + with target.open("xb") as output: + while chunk := source.read(1024 * 1024): + copied += len(chunk) + if copied > max_bytes: + raise ObservationBlocked("repository exceeds the staging file-count or byte limit") + output.write(chunk) + return copied + + def _subject_snapshot_evidence( source: Path, staged: Path, diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index 81e5177..35d6085 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -4,6 +4,7 @@ import hashlib import json +import os import shutil import sqlite3 import subprocess @@ -189,6 +190,45 @@ def test_cleanup_readback_fails_closed_for_nonzero_docker_and_remaining_local_ro assert _cleanup_local_root(local_root) == "local temporary evidence root still exists after cleanup" +def test_staging_keeps_the_open_file_identity_when_the_source_path_is_replaced( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = _repo(tmp_path) + outside = tmp_path / "outside.txt" + outside.write_text("outside value\n", encoding="utf-8") + staged = tmp_path / "staged" + staged.mkdir() + real_open = os.open + swapped = False + + def swapping_open( + path: str | bytes, + flags: int, + mode: int = 0o777, + *, + dir_fd: int | None = None, + ) -> int: + nonlocal swapped + descriptor = real_open(path, flags, mode, dir_fd=dir_fd) + if path == "input.txt" and dir_fd is not None and not swapped: + swapped = True + (repo / "input.txt").unlink() + (repo / "input.txt").symlink_to(outside) + return descriptor + + monkeypatch.setattr(os, "open", swapping_open) + + _stage_repository(repo, staged) + + assert (staged / "input.txt").read_text(encoding="utf-8") == "stable\n" + assert (repo / "input.txt").is_symlink() + second_stage = tmp_path / "second-stage" + second_stage.mkdir() + with pytest.raises(ObservationBlocked, match="symlink or unreadable file"): + _stage_repository(repo, second_stage) + + @requires_docker def test_read_only_command_passes_and_is_deterministic(tmp_path: Path) -> None: repo = _repo(tmp_path) @@ -863,6 +903,29 @@ def test_tampering_and_wrong_commit_or_schema_are_detected(tmp_path: Path) -> No output = tmp_path / "capsule" root_sha = export_capsule(capsule, output) assert verify_capsule(output, expect_root_sha256=root_sha)["valid"] is True + capsule_path = output / "capsule.json" + index_path = output / "capsule-index.json" + original_capsule = capsule_path.read_bytes() + original_index = index_path.read_bytes() + legacy_capsule = json.loads(original_capsule) + legacy_capsule["payload"]["observation"].pop("subject_snapshot") + legacy_capsule["payload"]["trust_manifest"].pop("repository_staged_tree_sha256") + legacy_capsule["integrity"]["payload_sha256"] = sha256_bytes( + canonical_json_bytes(legacy_capsule["payload"]) + ) + legacy_capsule_bytes = canonical_json_bytes(legacy_capsule) + capsule_path.write_bytes(legacy_capsule_bytes) + legacy_index = json.loads(original_index) + capsule_artifact = next( + artifact for artifact in legacy_index["artifacts"] if artifact["path"] == "capsule.json" + ) + capsule_artifact["sha256"] = sha256_bytes(legacy_capsule_bytes) + capsule_artifact["bytes"] = len(legacy_capsule_bytes) + index_path.write_bytes(canonical_json_bytes(legacy_index)) + legacy_result = verify_capsule(output) + assert legacy_result["valid"] is True, legacy_result + capsule_path.write_bytes(original_capsule) + index_path.write_bytes(original_index) wrong_root = verify_capsule(output, expect_root_sha256="0" * 64) assert wrong_root["valid"] is False assert wrong_root["authority"] == "unverified" @@ -879,16 +942,15 @@ def test_tampering_and_wrong_commit_or_schema_are_detected(tmp_path: Path) -> No "producer_commit_mismatch", "expected_schema_mismatch", } <= codes - original_index = (output / "capsule-index.json").read_bytes() index = json.loads(original_index) index["subject_commit"] = "0" * 40 - (output / "capsule-index.json").write_bytes(canonical_json_bytes(index)) + index_path.write_bytes(canonical_json_bytes(index)) semantic_tamper = verify_capsule(output) assert "index_subject_mismatch" in {item["code"] for item in semantic_tamper["errors"]} - (output / "capsule-index.json").write_bytes(original_index) - payload = bytearray((output / "capsule.json").read_bytes()) + index_path.write_bytes(original_index) + payload = bytearray(capsule_path.read_bytes()) payload[len(payload) // 2] ^= 1 - (output / "capsule.json").write_bytes(payload) + capsule_path.write_bytes(payload) tampered = verify_capsule(output) assert tampered["valid"] is False assert "artifact_tampered" in {item["code"] for item in tampered["errors"]} From 137e66675a3a3fd3f2d9f1ac9e8911ef7f5cd568 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 05:27:02 -0700 Subject: [PATCH 20/41] Fail closed on partial repository walks --- CHANGELOG.md | 3 +- docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md | 3 +- docs/PROOF-BEFORE-ACTION.md | 3 +- src/mcp_audit/proof_observer.py | 5 ++++ tests/test_proof_before_action.py | 38 ++++++++++++++++++++++++ 5 files changed, 49 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e9b63b..62c0b41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 uses one captured byte snapshot to prevent clean-after-read races. No-follow, directory-relative source descriptors close validation-to-copy link races, while legacy observation-v1 capsules remain verification-compatible and new - capsules require the staged subject binding. + capsules require the staged subject binding. Directory traversal/reopen + failures now block instead of silently omitting a changing subtree. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md index 3eecb89..ae57c96 100644 --- a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md +++ b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md @@ -67,7 +67,8 @@ platform. and commit-compares one captured byte set rather than rereading live files. - Repository files are copied from no-follow descriptors opened relative to walked directory descriptors, then the private copied bytes receive content - validation. A source-path replacement cannot redirect the copy. + validation. A source-path replacement cannot redirect the copy, and a + directory-walk reopen failure blocks rather than producing a partial tree. ## Residual threats and honest unknowns diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index 085d52c..ecd9810 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -88,7 +88,8 @@ The observer: byte comparison with the recorded subject commit are captured from this same immutable staged copy before execution. Files are opened relative to walked directory descriptors with link following disabled, copied from that open - identity, and validated again from the private staged bytes; + identity, and validated again from the private staged bytes. A directory + traversal or reopen failure blocks inspection instead of omitting a subtree; 2. creates a container with no host mount, no forwarded socket, network mode `none`, a read-only image root, `no-new-privileges`, and bounded CPU, memory, process, and tmpfs resources; diff --git a/src/mcp_audit/proof_observer.py b/src/mcp_audit/proof_observer.py index 6aed7b9..6020efb 100644 --- a/src/mcp_audit/proof_observer.py +++ b/src/mcp_audit/proof_observer.py @@ -409,6 +409,7 @@ def _stage_repository(source: Path, destination: Path) -> None: for directory, directory_names, file_names, directory_fd in os.fwalk( source, topdown=True, + onerror=_raise_repository_walk_error, follow_symlinks=False, ): relative_directory = Path(directory).relative_to(source) @@ -467,6 +468,10 @@ def _stage_repository(source: Path, destination: Path) -> None: os.close(descriptor) +def _raise_repository_walk_error(error: OSError) -> None: + raise ObservationBlocked("repository input tree changed or could not be traversed completely") from error + + def repository_input_is_in_scope(relative: Path) -> bool: """Return whether the observer copies this repository-relative path.""" return not any(part in _IGNORED_NAMES for part in relative.parts) diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index 35d6085..bd85998 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -8,6 +8,7 @@ import shutil import sqlite3 import subprocess +from collections.abc import Callable, Iterator from pathlib import Path import pytest @@ -229,6 +230,43 @@ def swapping_open( _stage_repository(repo, second_stage) +def test_staging_fails_closed_when_the_repository_walk_cannot_continue( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = _repo(tmp_path) + staged = tmp_path / "staged" + staged.mkdir() + real_fwalk = os.fwalk + captured: dict[str, object] = {} + + def capturing_fwalk( + top: str | os.PathLike[str], + topdown: bool = True, + onerror: Callable[[OSError], object] | None = None, + *, + follow_symlinks: bool = False, + dir_fd: int | None = None, + ) -> Iterator[tuple[str, list[str], list[str], int]]: + captured["onerror"] = onerror + return real_fwalk( + top, + topdown=topdown, + onerror=onerror, + follow_symlinks=follow_symlinks, + dir_fd=dir_fd, + ) + + monkeypatch.setattr(os, "fwalk", capturing_fwalk) + + _stage_repository(repo, staged) + + onerror = captured["onerror"] + assert callable(onerror) + with pytest.raises(ObservationBlocked, match="could not be traversed completely"): + onerror(OSError("directory disappeared")) + + @requires_docker def test_read_only_command_passes_and_is_deterministic(tmp_path: Path) -> None: repo = _repo(tmp_path) From fca395029ef36a9fa909cf32f22bba778b22a8c6 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 05:33:44 -0700 Subject: [PATCH 21/41] Detect silently skipped repository subtrees --- CHANGELOG.md | 3 +- docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md | 3 +- docs/PROOF-BEFORE-ACTION.md | 2 ++ src/mcp_audit/proof_observer.py | 6 ++++ tests/test_proof_before_action.py | 35 ++++++++++++++++++++++++ 5 files changed, 47 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62c0b41..b1a08aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 directory-relative source descriptors close validation-to-copy link races, while legacy observation-v1 capsules remain verification-compatible and new capsules require the staged subject binding. Directory traversal/reopen - failures now block instead of silently omitting a changing subtree. + failures and accepted-but-untraversed directories now block instead of + silently omitting a changing subtree. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md index ae57c96..5b9b0b5 100644 --- a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md +++ b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md @@ -68,7 +68,8 @@ platform. - Repository files are copied from no-follow descriptors opened relative to walked directory descriptors, then the private copied bytes receive content validation. A source-path replacement cannot redirect the copy, and a - directory-walk reopen failure blocks rather than producing a partial tree. + directory-walk reopen failure or silently skipped accepted directory blocks + rather than producing a partial tree. ## Residual threats and honest unknowns diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index ecd9810..27e5331 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -90,6 +90,8 @@ The observer: directory descriptors with link following disabled, copied from that open identity, and validated again from the private staged bytes. A directory traversal or reopen failure blocks inspection instead of omitting a subtree; + accepted directory listings are also reconciled with every directory actually + traversed so a runtime-silent skip is detected; 2. creates a container with no host mount, no forwarded socket, network mode `none`, a read-only image root, `no-new-privileges`, and bounded CPU, memory, process, and tmpfs resources; diff --git a/src/mcp_audit/proof_observer.py b/src/mcp_audit/proof_observer.py index 6020efb..6d36a0a 100644 --- a/src/mcp_audit/proof_observer.py +++ b/src/mcp_audit/proof_observer.py @@ -406,6 +406,8 @@ def _stage_repository(source: Path, destination: Path) -> None: raise ObservationBlocked("platform cannot securely open repository inputs without following links") file_count = 0 total_bytes = 0 + expected_directories = {"."} + traversed_directories: set[str] = set() for directory, directory_names, file_names, directory_fd in os.fwalk( source, topdown=True, @@ -413,6 +415,7 @@ def _stage_repository(source: Path, destination: Path) -> None: follow_symlinks=False, ): relative_directory = Path(directory).relative_to(source) + traversed_directories.add(relative_directory.as_posix()) retained_directories: list[str] = [] for name in sorted(directory_names): relative = relative_directory / name @@ -427,6 +430,7 @@ def _stage_repository(source: Path, destination: Path) -> None: if not stat.S_ISDIR(mode): raise ObservationBlocked(f"input contains a symlink: {relative.as_posix()}") retained_directories.append(name) + expected_directories.add(relative.as_posix()) (destination / relative).mkdir(parents=True, exist_ok=True) directory_names[:] = retained_directories @@ -466,6 +470,8 @@ def _stage_repository(source: Path, destination: Path) -> None: finally: if descriptor >= 0: os.close(descriptor) + if traversed_directories != expected_directories: + raise ObservationBlocked("repository input tree changed or could not be traversed completely") def _raise_repository_walk_error(error: OSError) -> None: diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index bd85998..6f0a11c 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -267,6 +267,41 @@ def capturing_fwalk( onerror(OSError("directory disappeared")) +def test_staging_detects_a_directory_silently_skipped_by_fwalk( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = _repo(tmp_path) + (repo / "child").mkdir() + (repo / "child/config.json").write_text("{}\n", encoding="utf-8") + staged = tmp_path / "staged" + staged.mkdir() + real_fwalk = os.fwalk + + def skipping_fwalk( + top: str | os.PathLike[str], + topdown: bool = True, + onerror: Callable[[OSError], object] | None = None, + *, + follow_symlinks: bool = False, + dir_fd: int | None = None, + ) -> Iterator[tuple[str, list[str], list[str], int]]: + for entry in real_fwalk( + top, + topdown=topdown, + onerror=onerror, + follow_symlinks=follow_symlinks, + dir_fd=dir_fd, + ): + if Path(entry[0]).name != "child": + yield entry + + monkeypatch.setattr(os, "fwalk", skipping_fwalk) + + with pytest.raises(ObservationBlocked, match="could not be traversed completely"): + _stage_repository(repo, staged) + + @requires_docker def test_read_only_command_passes_and_is_deterministic(tmp_path: Path) -> None: repo = _repo(tmp_path) From 95f2f9a2c579488213d657a11551b7f9559466e9 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 05:44:02 -0700 Subject: [PATCH 22/41] Validate nested trust evidence fields --- CHANGELOG.md | 4 +- docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md | 3 ++ docs/PROOF-BEFORE-ACTION.md | 3 ++ src/mcp_audit/proof_trust.py | 68 +++++++++++++++++++----- tests/test_proof_before_action.py | 52 ++++++++++++++++++ 5 files changed, 116 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1a08aa..01eda67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 while legacy observation-v1 capsules remain verification-compatible and new capsules require the staged subject binding. Directory traversal/reopen failures and accepted-but-untraversed directories now block instead of - silently omitting a changing subtree. + silently omitting a changing subtree. Nested mcp-trust seed identities and + grade-bearing snapshot fields are type-validated before any row can become + current evidence. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md index 5b9b0b5..c6dcb9b 100644 --- a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md +++ b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md @@ -59,6 +59,9 @@ platform. root instead of accepting an arbitrary ancestor repository. - Every required mcp-trust input is read back from the recorded trust commit and compared byte-for-byte before grade details can remain authoritative. +- Seed identities and all grade-bearing snapshot fields receive strict nested + type validation; malformed committed rows cannot be coerced into current + evidence. - Git-ignored subject files that enter the observer staging inventory force the subject repository to dirty/unbound; ignored dependency caches and generated metadata excluded from staging do not alter subject provenance. diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index 27e5331..fbb2186 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -136,6 +136,9 @@ untracked files do not count as clean authority. Missing, stale, masked, ambiguous, unmatched, dirty-source, commit-unbound, or version-unbound evidence remains explicit in the manifest. A grade is historical evidence about an observed MCP surface, not an endorsement or runtime-safety proof. +Seed identity/slug fields and every grade-bearing snapshot field must also match +their documented scalar/object shapes; malformed nested data makes the complete +local trust source UNKNOWN instead of being coerced into evidence. Freshness is evaluated at the current UTC date, recorded separately from the snapshot generation timestamp. Runs are byte-stable within that date; evidence diff --git a/src/mcp_audit/proof_trust.py b/src/mcp_audit/proof_trust.py index b1821eb..897c55e 100644 --- a/src/mcp_audit/proof_trust.py +++ b/src/mcp_audit/proof_trust.py @@ -10,7 +10,7 @@ import tomllib from datetime import UTC, datetime from pathlib import Path -from typing import Any, Literal +from typing import Any, Literal, cast from urllib.parse import urlsplit, urlunsplit from mcp_audit.proof_models import ( @@ -514,17 +514,59 @@ def _valid_trust_input_shapes( ) -> bool: if not isinstance(snapshot, dict) or not isinstance(spec_shift, dict): return False + if ( + not isinstance(snapshot.get("schema_version"), (int, str)) + or not isinstance(snapshot.get("generated_at"), str) + or not isinstance(spec_shift.get("format_version"), (int, str)) + or not isinstance(spec_shift.get("servers"), dict) + ): + return False records = snapshot.get("servers") - if not isinstance(records, list) or not all(isinstance(item, dict) for item in records): + if not isinstance(records, list) or not all(_valid_snapshot_record(item) for item in records): return False seed_rows = seed if isinstance(seed, list) else seed.get("servers") if isinstance(seed, dict) else None - if not isinstance(seed_rows, list) or not all(isinstance(item, dict) for item in seed_rows): - return False - if not all(isinstance(item.get("source", {}), dict) for item in seed_rows): + if not isinstance(seed_rows, list) or not all(_valid_seed_row(item) for item in seed_rows): return False return isinstance(masked, list) and all(isinstance(item, str) for item in masked) +def _valid_seed_row(value: Any) -> bool: + if not isinstance(value, dict) or not isinstance(value.get("slug"), str) or not value["slug"]: + return False + source = value.get("source") + return ( + isinstance(source, dict) + and isinstance(source.get("kind"), str) + and bool(source["kind"]) + and isinstance(source.get("reference"), str) + and bool(source["reference"]) + ) + + +def _valid_snapshot_record(value: Any) -> bool: + required_strings = ( + "slug", + "grade", + "transparency", + "scanned_at", + "engine", + "engine_version", + "scan_mode", + ) + if not isinstance(value, dict) or not all( + isinstance(value.get(field), str) and bool(value[field]) for field in required_strings + ): + return False + sandbox = value.get("sandbox") + return ( + isinstance(sandbox, dict) + and isinstance(sandbox.get("mode"), str) + and bool(sandbox["mode"]) + and isinstance(sandbox.get("network"), str) + and bool(sandbox["network"]) + ) + + def _without_trust_source_authority( evidence: TrustEvidence, reason: str, @@ -565,7 +607,7 @@ def _match_dependency( match_state="unmatched", unknown_reasons=["no mcp-trust catalog identity matched"], ) - slug = str(candidates[0].get("slug", "")) + slug = cast(str, candidates[0]["slug"]) if slug in masked: return TrustEvidence( state="masked", @@ -605,7 +647,7 @@ def _match_dependency( if version_alignment in {"dependency_unresolved", "evidence_unversioned"}: state = "unverifiable" if state == "current" else state unknowns.append("evidence is not bound to an exact dependency version") - sandbox = record.get("sandbox", {}) + sandbox = cast(dict[str, Any], record["sandbox"]) network: Literal["verified_none", "unknown", "not_applicable"] = ( "verified_none" if record.get("scan_mode") == "mcpaudit-local-network-off" @@ -621,12 +663,12 @@ def _match_dependency( state=state, # type: ignore[arg-type] match_state="exact", slug=slug, - grade=str(record.get("grade")) if record.get("grade") is not None else None, - transparency=record.get("transparency"), - scanned_at=record.get("scanned_at"), - engine=record.get("engine"), - engine_version=record.get("engine_version"), - scan_mode=record.get("scan_mode"), + grade=cast(str, record["grade"]), + transparency=cast(str, record["transparency"]), + scanned_at=cast(str, record["scanned_at"]), + engine=cast(str, record["engine"]), + engine_version=cast(str, record["engine_version"]), + scan_mode=cast(str, record["scan_mode"]), network_isolation=network, version_alignment=version_alignment, unknown_reasons=unknowns, diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index 6f0a11c..e9a17fb 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -908,6 +908,58 @@ def test_wrong_shaped_trust_inputs_become_structured_unknown( assert "mcp-trust source has an unsupported data shape" in manifest.limitations +@pytest.mark.parametrize( + ("relative", "field_path"), + [ + ("src/mcp_trust/catalog/seed_servers.json", (0, "slug")), + ("src/mcp_trust/catalog/seed_servers.json", (0, "source")), + ("src/mcp_trust/catalog/seed_servers.json", (0, "source", "kind")), + ("src/mcp_trust/catalog/seed_servers.json", (0, "source", "reference")), + ("src/mcp_trust/catalog_snapshot.json", ("schema_version",)), + ("src/mcp_trust/catalog_snapshot.json", ("generated_at",)), + ("src/mcp_trust/catalog_snapshot.json", ("servers", 0, "slug")), + ("src/mcp_trust/catalog_snapshot.json", ("servers", 0, "grade")), + ("src/mcp_trust/catalog_snapshot.json", ("servers", 0, "transparency")), + ("src/mcp_trust/catalog_snapshot.json", ("servers", 0, "scanned_at")), + ("src/mcp_trust/catalog_snapshot.json", ("servers", 0, "engine")), + ("src/mcp_trust/catalog_snapshot.json", ("servers", 0, "engine_version")), + ("src/mcp_trust/catalog_snapshot.json", ("servers", 0, "scan_mode")), + ("src/mcp_trust/catalog_snapshot.json", ("servers", 0, "sandbox")), + ("src/mcp_trust/catalog_snapshot.json", ("servers", 0, "sandbox", "mode")), + ("src/mcp_trust/catalog_snapshot.json", ("servers", 0, "sandbox", "network")), + ("src/mcp_trust/core/spec_shift_verdicts.json", ("format_version",)), + ("src/mcp_trust/core/spec_shift_verdicts.json", ("servers",)), + ], +) +def test_malformed_nested_trust_fields_become_structured_unknown( + tmp_path: Path, + relative: str, + field_path: tuple[str | int, ...], +) -> None: + repo = _repo(tmp_path) + (repo / ".mcp.json").write_text( + '{"mcpServers":{"known":{"command":"npx","args":["@fixture/known-mcp"]}}}', + encoding="utf-8", + ) + trust = _trust_fixture(tmp_path) + path = trust / relative + payload = json.loads(path.read_text(encoding="utf-8")) + cursor = payload + for field in field_path[:-1]: + cursor = cursor[field] + cursor[field_path[-1]] = ["malformed"] + path.write_text(json.dumps(payload), encoding="utf-8") + _commit_trust_fixture(trust, "commit malformed nested trust input") + + manifest = build_release_trust_manifest(repo, trust) + + assert manifest.discovery_coverage == "unknown" + assert manifest.trust_source is None + assert manifest.entries[0].evidence.state == "unverifiable" + assert manifest.entries[0].evidence.grade is None + assert "mcp-trust source has an unsupported data shape" in manifest.limitations + + def test_installed_module_does_not_inherit_an_unrelated_ancestor_commit( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From f9cc23ac4eb22a8ba2409761c0676784dd6f54b4 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 06:06:37 -0700 Subject: [PATCH 23/41] Fail closed on trust chronology and network unknowns --- CHANGELOG.md | 3 +- docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md | 2 + docs/PROOF-BEFORE-ACTION.md | 3 ++ src/mcp_audit/proof_trust.py | 19 ++++++++ tests/test_proof_before_action.py | 62 ++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01eda67..edd8b6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 failures and accepted-but-untraversed directories now block instead of silently omitting a changing subtree. Nested mcp-trust seed identities and grade-bearing snapshot fields are type-validated before any row can become - current evidence. + current evidence; snapshot chronology and network-isolation proof also fail + closed before a record can remain current. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md index c6dcb9b..1384d93 100644 --- a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md +++ b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md @@ -62,6 +62,8 @@ platform. - Seed identities and all grade-bearing snapshot fields receive strict nested type validation; malformed committed rows cannot be coerced into current evidence. +- Invalid, future, or scan-preceding snapshot generation timestamps invalidate + the trust source, and unproven network isolation prevents `current` evidence. - Git-ignored subject files that enter the observer staging inventory force the subject repository to dirty/unbound; ignored dependency caches and generated metadata excluded from staging do not alter subject provenance. diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index fbb2186..2b51624 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -139,6 +139,9 @@ observed MCP surface, not an endorsement or runtime-safety proof. Seed identity/slug fields and every grade-bearing snapshot field must also match their documented scalar/object shapes; malformed nested data makes the complete local trust source UNKNOWN instead of being coerced into evidence. +Snapshot generation time must be a valid, non-future, timezone-aware timestamp +that is not earlier than any contained scan. A record without proven network +isolation cannot be `current`, even when its dependency match is otherwise exact. Freshness is evaluated at the current UTC date, recorded separately from the snapshot generation timestamp. Runs are byte-stable within that date; evidence diff --git a/src/mcp_audit/proof_trust.py b/src/mcp_audit/proof_trust.py index 897c55e..0e2ba53 100644 --- a/src/mcp_audit/proof_trust.py +++ b/src/mcp_audit/proof_trust.py @@ -524,12 +524,30 @@ def _valid_trust_input_shapes( records = snapshot.get("servers") if not isinstance(records, list) or not all(_valid_snapshot_record(item) for item in records): return False + generated_at = _trust_timestamp(snapshot["generated_at"]) + if generated_at is None or generated_at > datetime.now(UTC): + return False + scanned_at = [_trust_timestamp(item["scanned_at"]) for item in records] + if any(value is None or value > generated_at for value in scanned_at): + return False seed_rows = seed if isinstance(seed, list) else seed.get("servers") if isinstance(seed, dict) else None if not isinstance(seed_rows, list) or not all(_valid_seed_row(item) for item in seed_rows): return False return isinstance(masked, list) and all(isinstance(item, str) for item in masked) +def _trust_timestamp(value: Any) -> datetime | None: + if not isinstance(value, str) or not value: + return None + try: + timestamp = datetime.fromisoformat(value.replace("Z", "+00:00")) + if timestamp.tzinfo is None: + return None + return timestamp.astimezone(UTC) + except (OverflowError, ValueError): + return None + + def _valid_seed_row(value: Any) -> bool: if not isinstance(value, dict) or not isinstance(value.get("slug"), str) or not value["slug"]: return False @@ -658,6 +676,7 @@ def _match_dependency( else "unknown" ) if network == "unknown": + state = "unverifiable" if state == "current" else state unknowns.append("mcp-trust record does not prove network isolation") return TrustEvidence( state=state, # type: ignore[arg-type] diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index e9a17fb..630ee06 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -775,6 +775,68 @@ def test_stale_trust_evidence_is_historical_not_current(tmp_path: Path) -> None: assert manifest.trust_source.evaluated_at != manifest.trust_source.snapshot_generated_at +@pytest.mark.parametrize( + "generated_at", + [ + "not-a-date", + "2026-07-18T00:00:00", + "2026-06-30T00:00:00+00:00", + "2999-01-01T00:00:00+00:00", + ], +) +def test_invalid_or_impossible_trust_generation_time_is_unknown( + tmp_path: Path, + generated_at: str, +) -> None: + repo = _repo(tmp_path) + (repo / ".mcp.json").write_text( + '{"mcpServers":{"known":{"command":"npx","args":["@fixture/known-mcp"]}}}', + encoding="utf-8", + ) + trust = _trust_fixture(tmp_path) + snapshot_path = trust / "src/mcp_trust/catalog_snapshot.json" + snapshot = json.loads(snapshot_path.read_text()) + snapshot["generated_at"] = generated_at + snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8") + _commit_trust_fixture(trust, "invalid trust generation time") + + manifest = build_release_trust_manifest(repo, trust) + + assert manifest.discovery_coverage == "unknown" + assert manifest.trust_source is None + assert manifest.entries[0].evidence.state == "unverifiable" + assert manifest.entries[0].evidence.grade is None + assert "mcp-trust source has an unsupported data shape" in manifest.limitations + + +def test_unproven_network_isolation_cannot_be_current_trust_evidence(tmp_path: Path) -> None: + repo = _repo(tmp_path) + endpoint = "https://example.invalid/mcp" + (repo / ".mcp.json").write_text( + json.dumps({"mcpServers": {"known": {"url": endpoint}}}), + encoding="utf-8", + ) + trust = _trust_fixture(tmp_path) + seed_path = trust / "src/mcp_trust/catalog/seed_servers.json" + seed = json.loads(seed_path.read_text()) + seed[0]["source"] = {"kind": "remote", "reference": endpoint} + seed_path.write_text(json.dumps(seed), encoding="utf-8") + snapshot_path = trust / "src/mcp_trust/catalog_snapshot.json" + snapshot = json.loads(snapshot_path.read_text()) + snapshot["servers"][0]["scan_mode"] = "mcpaudit-local-network-unknown" + snapshot["servers"][0]["sandbox"]["network"] = "unknown" + snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8") + _commit_trust_fixture(trust, "network isolation unproven") + + evidence = build_release_trust_manifest(repo, trust).entries[0].evidence + + assert evidence.match_state == "exact" + assert evidence.version_alignment == "not_applicable" + assert evidence.network_isolation == "unknown" + assert evidence.state == "unverifiable" + assert "mcp-trust record does not prove network isolation" in evidence.unknown_reasons + + def test_dirty_trust_source_cannot_emit_authoritative_grade_details(tmp_path: Path) -> None: repo = _repo(tmp_path) (repo / ".mcp.json").write_text( From b1652f27225f508c492f1732aa777db27f69d67c Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 06:34:22 -0700 Subject: [PATCH 24/41] Make unobservable effects fail closed --- CHANGELOG.md | 5 +- docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md | 22 +-- docs/PROOF-BEFORE-ACTION.md | 31 ++-- src/mcp_audit/proof_observer.py | 97 ++++++++++-- src/mcp_audit/proof_trust.py | 10 +- tests/test_proof_before_action.py | 192 +++++++++++++++++++++-- 6 files changed, 309 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edd8b6a..7345ae5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 silently omitting a changing subtree. Nested mcp-trust seed identities and grade-bearing snapshot fields are type-validated before any row can become current evidence; snapshot chronology and network-isolation proof also fail - closed before a record can remain current. + closed before a record can remain current. Filesystem and database final-state + snapshots now report incomplete for transient-attempt coverage, IPv6 counters + join IPv4 network evidence, and same-day trust freshness uses a deterministic + end-of-day bound. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md index 1384d93..981f617 100644 --- a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md +++ b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md @@ -83,11 +83,11 @@ platform. | Container, VM, or hypervisor escape | Unknown | Could bypass the container controls. A capsule records containment as `partial`. | | Docker engine host or optional VM sharing | Not a proven isolation boundary | The engine layer may expose broader host-adjacent state than the runtime container. A hostile-kernel test should use a fresh mountless VM instead. | | macOS Keychain, TCC, XPC, Apple Events, GUI, devices, and host kernel | Unobserved | The Linux fixture cannot justify claims about these surfaces. | -| Transient create-delete or write-restore | Unobserved | Final-state hashing can miss an attempt that leaves no persisted delta. | +| Transient create-delete or write-restore | Explicitly incomplete | Final-state hashing can miss an attempt that leaves no persisted delta, so the filesystem surface cannot support `pass`. | | Nested or very short-lived child processes | Final state quiesced; identity attribution incomplete | Surviving descendants are terminated before the final archive, but child executable identities and transient effects are not completely attributed. | -| SQLite transactions with no final delta | Unobserved | Semantic comparison proves final content, not every query or transaction attempt. | +| SQLite transactions with no final delta | Explicitly incomplete | Semantic comparison proves final content, not every query or transaction attempt, so the database surface cannot support `pass`. | | Non-SQLite databases | File-level only | Semantic records and remote database effects are unknown. | -| Network destination | Unobserved | Namespace counters reveal common IP/TCP/UDP attempts, not the requested hostname or endpoint. | +| Network destination | Unobserved | IPv4/IPv6 IP and UDP counters plus family-agnostic Linux TCP counters reveal attempts, not the requested hostname or endpoint. Missing or regressed counters make the surface incomplete. | | Loopback inside the namespace | Available | A command can contact its own processes; the evidence marks attempts but does not call loopback external contact. | | Output links or special files | Fail-closed | Collection stops; the effect is not silently omitted and no completed capsule is issued. | | Unknown secret formats or low-entropy secret hashes | Residual risk | Redaction is best effort, and a digest can sometimes be guessed. Review declarations and commands before sharing capsules. | @@ -98,13 +98,17 @@ platform. ## False claims the product must not make -A successful run means the persisted regular-file/SQLite state and observable -network counters matched the declaration within this container experiment. It -does not mean the command is safe, cannot mutate, is sandboxed on macOS, is free -of data exfiltration paths, or is approved for release. +An `unknown` run can mean the persisted regular-file/SQLite state and observable +network counters matched the declaration while transient write/transaction +attempts remained unobservable. It does not mean the command is safe, cannot +mutate, is sandboxed on macOS, is free of data exfiltration paths, or is approved +for release. -`pass` is a deterministic comparison result. Release authority still belongs to -the operator and must account for every recorded limitation and unknown. +The v1 final-state observer does not emit `pass` for a whole action because its +filesystem and database attempt surfaces remain incomplete. The schema retains +`pass` for compatibility with complete observation mechanisms; release +authority still belongs to the operator and must account for every limitation +and unknown. ## Safer high-risk profile diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index 2b51624..06db557 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -46,6 +46,12 @@ comparison block or unknown, and `2` when observation or input validation is blocked. It always uses Docker network mode `none`; there is no option to enable network access. +The v1 final-state observer intentionally returns `1` with verdict `unknown` +when no persisted change is found, because transient filesystem and database +attempts are not traced. Persisted undeclared effects still produce `block`. +Exit `0` remains reserved for a future or alternate observer that can mark every +requested surface complete without silently widening the claim. + The output directory contains: - `capsule.json`: canonical evidence; @@ -106,16 +112,20 @@ The observer: 4. terminates every surviving command descendant, verifies every Linux task is terminal from `/proc`, and only then streams one attached archive containing the disposable workspace and root-owned evidence; -5. collects file hashes, SQLite schema/row digests, and Linux IP/TCP/UDP counter - deltas from that quiesced archive; +5. collects file hashes, SQLite schema/row digests, and Linux IPv4/IPv6 + IP/TCP/UDP counter deltas from that quiesced archive; 6. removes the container and temporary staging image. File and SQLite comparisons are complete for persisted regular files that can be -collected. `attempted: null` means no attempt could be inferred; it does not mean -the action was proven unable to attempt the effect. Network counters distinguish -an observed attempt from no counter change, but cannot identify the requested -destination. Link or special-file output blocks collection rather than silently -disappearing. The command cannot write the observer-owned evidence tmpfs. +collected, but their surfaces remain `complete: false`: final-state comparison +cannot observe transient create-delete, write-restore, or transaction attempts. +Consequently a clean final snapshot is `unknown`, never proof of read-only +behavior. IPv4/IPv6 IP and UDP counters plus Linux's family-agnostic TCP counters +distinguish an observed attempt from no counter change, but cannot identify the +requested destination. Missing or regressed required counters make the network +surface incomplete. Link or special-file output blocks collection rather than +silently disappearing. The command cannot write the observer-owned evidence +tmpfs. Command output is redirected there by PID 1 under an OS file-size limit before it is hashed and omitted. @@ -143,9 +153,10 @@ Snapshot generation time must be a valid, non-future, timezone-aware timestamp that is not earlier than any contained scan. A record without proven network isolation cannot be `current`, even when its dependency match is otherwise exact. -Freshness is evaluated at the current UTC date, recorded separately from the -snapshot generation timestamp. Runs are byte-stable within that date; evidence -can correctly cross the 90-day stale boundary on a later date. +Freshness is evaluated at the deterministic end of the current UTC date, +recorded separately from the snapshot generation timestamp. Runs are byte-stable +within that date, same-day scans are eligible to remain current, and evidence can +correctly cross the 90-day stale boundary on a later date. ## Schemas and compatibility diff --git a/src/mcp_audit/proof_observer.py b/src/mcp_audit/proof_observer.py index 6d36a0a..2469239 100644 --- a/src/mcp_audit/proof_observer.py +++ b/src/mcp_audit/proof_observer.py @@ -99,6 +99,7 @@ cp -R /pba-input/. /workspace/ chmod -R a+rwX /workspace cat /proc/net/snmp > /pba/network.before +cat /proc/net/snmp6 > /pba/network6.before ulimit -f 512 set +e timeout --signal=TERM --kill-after=1 "${PBA_TIMEOUT_SECONDS}s" \ @@ -154,6 +155,7 @@ exit 3 fi cat /proc/net/snmp > /pba/network.after +cat /proc/net/snmp6 > /pba/network6.after printf '%s\n' "$rc" > /pba/exit-code touch /pba/complete tar -C / -cf - workspace pba @@ -320,6 +322,8 @@ def observe_command( network = _network_evidence( evidence / "network.before", evidence / "network.after", + evidence / "network6.before", + evidence / "network6.after", timed_out=timed_out, ) stdout = _read_bounded(evidence / "stdout") @@ -330,10 +334,10 @@ def observe_command( outcome="succeeded" if file_changes else "unknown", persisted="changed" if file_changes else "unchanged", mechanism="complete before/after hash inventory of the disposable workspace", - complete=True, + complete=False, limitations=[ - "Transient create-delete or write-restore attempts are not observable " - "without syscall tracing." + "Persisted regular-file state is completely compared, but transient " + "create-delete or write-restore attempts are not observable without syscall tracing." ], ) database = SurfaceObservation( @@ -342,11 +346,12 @@ def observe_command( outcome="succeeded" if database_changes else "unknown", persisted="changed" if database_changes else "unchanged", mechanism="SQLite schema, row-count, and row-digest comparison plus file hashes", - complete=not any(change.change == "unreadable" for change in database_changes), + complete=False, limitations=[ "Only copied SQLite files receive semantic inspection; other databases " "remain file-level evidence.", - "Transient transactions that leave no SQLite or journal delta are not observable.", + "Persisted SQLite state is compared unless reported unreadable, but transient " + "transactions that leave no SQLite or journal delta are not observable.", ], ) recorded_argv, recorded_argv_sha256 = _command_argv_evidence(command) @@ -725,7 +730,7 @@ def _require_image_tools(image: str) -> None: f"PATH={_RUNTIME_PATH}", image, "-c", - "test -r /proc/net/snmp && command -v tar >/dev/null " + "test -r /proc/net/snmp && test -r /proc/net/snmp6 && command -v tar >/dev/null " "&& command -v timeout >/dev/null && command -v setpriv >/dev/null", ], timeout=20, @@ -1038,8 +1043,21 @@ def _diff_databases( return changes -def _network_evidence(before: Path, after: Path, *, timed_out: bool) -> NetworkEvidence: - if timed_out or not before.is_file() or not after.is_file(): +def _network_evidence( + before: Path, + after: Path, + before6: Path, + after6: Path, + *, + timed_out: bool, +) -> NetworkEvidence: + if ( + timed_out + or not before.is_file() + or not after.is_file() + or not before6.is_file() + or not after6.is_file() + ): return NetworkEvidence( surface=SurfaceObservation( attempted=None, @@ -1053,19 +1071,50 @@ def _network_evidence(before: Path, after: Path, *, timed_out: bool) -> NetworkE ) old = _parse_snmp(before) new = _parse_snmp(after) - keys = ( + old6 = _parse_snmp6(before6) + new6 = _parse_snmp6(after6) + ipv4_keys = ( ("Tcp", "ActiveOpens"), ("Tcp", "PassiveOpens"), ("Tcp", "AttemptFails"), ("Udp", "OutDatagrams"), ("Ip", "OutRequests"), ) - deltas = { - f"{protocol}.{field}": max( - 0, new.get(protocol, {}).get(field, 0) - old.get(protocol, {}).get(field, 0) + ipv6_keys = ("Ip6OutRequests", "Udp6OutDatagrams") + if any( + field not in old.get(protocol, {}) or field not in new.get(protocol, {}) + for protocol, field in ipv4_keys + ) or any(field not in old6 or field not in new6 for field in ipv6_keys): + return NetworkEvidence( + surface=SurfaceObservation( + attempted=None, + decision="unknown", + outcome="unknown", + persisted="unknown", + mechanism="Linux IPv4 and IPv6 network namespace counters", + complete=False, + limitations=["Required IPv4 or IPv6 network counters were unavailable."], + ) ) - for protocol, field in keys + if any(new[protocol][field] < old[protocol][field] for protocol, field in ipv4_keys) or any( + new6[field] < old6[field] for field in ipv6_keys + ): + return NetworkEvidence( + surface=SurfaceObservation( + attempted=None, + decision="unknown", + outcome="unknown", + persisted="unknown", + mechanism="Linux IPv4 and IPv6 network namespace counters", + complete=False, + limitations=["A required network counter regressed or wrapped during observation."], + ) + ) + deltas = { + f"{protocol}.{field}": new[protocol][field] - old[protocol][field] for protocol, field in ipv4_keys } + deltas["Ip6.OutRequests"] = new6["Ip6OutRequests"] - old6["Ip6OutRequests"] + deltas["Udp6.OutDatagrams"] = new6["Udp6OutDatagrams"] - old6["Udp6OutDatagrams"] attempted = any(value > 0 for value in deltas.values()) failed = deltas["Tcp.AttemptFails"] > 0 return NetworkEvidence( @@ -1074,11 +1123,14 @@ def _network_evidence(before: Path, after: Path, *, timed_out: bool) -> NetworkE decision="blocked" if failed else "unknown" if attempted else "not_applicable", outcome="failed" if failed else "unknown" if attempted else "not_applicable", persisted="unchanged", - mechanism="per-container /proc/net/snmp counter delta under Docker network mode none", + mechanism=( + "per-container /proc/net/snmp and /proc/net/snmp6 counter deltas " + "under Docker network mode none" + ), complete=True, limitations=[ - "Counters identify common IP/TCP/UDP activity but not the requested " - "destination or every socket family.", + "IPv4/IPv6 IP and UDP counters plus family-agnostic Linux TCP counters " + "identify activity but not the requested destination.", "Docker network mode none proves no ordinary external interface, not " "resistance to container escape.", ], @@ -1101,6 +1153,19 @@ def _parse_snmp(path: Path) -> dict[str, dict[str, int]]: return result +def _parse_snmp6(path: Path) -> dict[str, int]: + result: dict[str, int] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + parts = line.split() + if len(parts) != 2: + continue + try: + result[parts[0]] = int(parts[1]) + except ValueError: + continue + return result + + def _read_exit_code(path: Path) -> int | None: try: return int(path.read_text(encoding="utf-8").strip()) diff --git a/src/mcp_audit/proof_trust.py b/src/mcp_audit/proof_trust.py index 0e2ba53..e762452 100644 --- a/src/mcp_audit/proof_trust.py +++ b/src/mcp_audit/proof_trust.py @@ -423,7 +423,7 @@ def _join_trust( ) ) snapshot_generated_at = str(snapshot.get("generated_at", "")) or "unknown" - evaluated_at = datetime.now(UTC).date().isoformat() + "T00:00:00+00:00" + evaluated_at = datetime.now(UTC).date().isoformat() + "T23:59:59.999999+00:00" source = TrustSource( repository_commit=trust_commit, dirty=trust_dirty, @@ -675,9 +675,13 @@ def _match_dependency( if isinstance(sandbox, dict) and sandbox.get("mode") == "not_applicable" else "unknown" ) - if network == "unknown": + if network != "verified_none": state = "unverifiable" if state == "current" else state - unknowns.append("mcp-trust record does not prove network isolation") + unknowns.append( + "mcp-trust record does not prove network isolation" + if network == "unknown" + else "network isolation was not applicable to the recorded scan" + ) return TrustEvidence( state=state, # type: ignore[arg-type] match_state="exact", diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index 630ee06..e519279 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -9,6 +9,7 @@ import sqlite3 import subprocess from collections.abc import Callable, Iterator +from datetime import UTC, datetime from pathlib import Path import pytest @@ -37,6 +38,7 @@ _cleanup_local_root, _command_argv_evidence, _file_snapshot, + _network_evidence, _redact_argv, _stage_repository, _subject_snapshot_evidence, @@ -303,7 +305,7 @@ def skipping_fwalk( @requires_docker -def test_read_only_command_passes_and_is_deterministic(tmp_path: Path) -> None: +def test_read_only_final_state_is_unknown_and_deterministic(tmp_path: Path) -> None: repo = _repo(tmp_path) command = ["node", "-e", "require('fs').readFileSync('input.txt')"] first = observe_command(repo, command, image="node:24-slim") @@ -313,12 +315,56 @@ def test_read_only_command_passes_and_is_deterministic(tmp_path: Path) -> None: assert first.network.surface.attempted is False first_comparison = compare_bill(_declaration(), first) second_comparison = compare_bill(_declaration(), second) - assert first_comparison.verdict == "pass" + assert first_comparison.verdict == "unknown" + assert "observation_incomplete" in {item.code for item in first_comparison.findings} first_capsule = build_capsule(_declaration(), first, first_comparison, _empty_trust(repo, first)) second_capsule = build_capsule(_declaration(), second, second_comparison, _empty_trust(repo, second)) assert canonical_json_bytes(first_capsule) == canonical_json_bytes(second_capsule) +@requires_docker +def test_transient_file_write_cannot_be_reported_as_read_only(tmp_path: Path) -> None: + repo = _repo(tmp_path) + observation = observe_command( + repo, + [ + "node", + "-e", + "const fs=require('fs');fs.writeFileSync('transient.txt','x');fs.unlinkSync('transient.txt')", + ], + image="node:24-slim", + ) + + assert observation.file_changes == [] + assert observation.filesystem.complete is False + comparison = compare_bill(_declaration(), observation) + assert comparison.verdict == "unknown" + assert "observation_incomplete" in {item.code for item in comparison.findings} + + +@requires_docker +def test_rolled_back_database_write_cannot_be_reported_as_read_only(tmp_path: Path) -> None: + repo = _repo(tmp_path) + database = sqlite3.connect(repo / "seeded.db") + database.execute("CREATE TABLE items(id INTEGER PRIMARY KEY, value TEXT NOT NULL)") + database.execute("INSERT INTO items(value) VALUES ('before')") + database.commit() + database.close() + code = ( + "const {DatabaseSync}=require('node:sqlite');" + "const db=new DatabaseSync('seeded.db');" + "db.exec(\"BEGIN;UPDATE items SET value='transient' WHERE id=1;ROLLBACK;\");db.close()" + ) + + observation = observe_command(repo, ["node", "-e", code], image="node:24-slim") + + assert observation.database_changes == [] + assert observation.database.complete is False + comparison = compare_bill(_declaration(), observation) + assert comparison.verdict == "unknown" + assert "observation_incomplete" in {item.code for item in comparison.findings} + + @requires_docker def test_background_descendant_cannot_mutate_after_observation_completion(tmp_path: Path) -> None: repo = _repo(tmp_path) @@ -352,7 +398,7 @@ def test_background_descendant_cannot_mutate_after_observation_completion(tmp_pa assert observation.command.timed_out is False assert observation.command.stdout_sha256 == sha256_bytes(b"") assert observation.file_changes == [] - assert compare_bill(_declaration(), observation).verdict == "pass" + assert compare_bill(_declaration(), observation).verdict == "unknown" @requires_docker @@ -404,7 +450,7 @@ def test_command_is_unprivileged_and_cannot_rewrite_observer_evidence(tmp_path: assert profile.capabilities_bounding == 0 assert profile.capabilities_ambient == 0 assert profile.no_new_privileges is True - assert compare_bill(_declaration(), observation).verdict == "pass" + assert compare_bill(_declaration(), observation).verdict == "unknown" @requires_docker @@ -433,7 +479,9 @@ def test_undeclared_file_write_is_detected_and_blocked(tmp_path: Path) -> None: destinations={"files": ["created.txt"], "databases": [], "network": []}, side_effects={"filesystem": "write", "database": "none", "network": "none"}, ) - assert compare_bill(declared_write, observation).verdict == "pass" + declared_comparison = compare_bill(declared_write, observation) + assert declared_comparison.verdict == "unknown" + assert "observation_incomplete" in {item.code for item in declared_comparison.findings} @requires_docker @@ -462,7 +510,8 @@ def test_seeded_sqlite_mutation_is_semantically_detected(tmp_path: Path) -> None side_effects={"filesystem": "none", "database": "write", "network": "none"}, ) declared_comparison = compare_bill(declared_database_write, observation) - assert declared_comparison.verdict == "pass" + assert declared_comparison.verdict == "unknown" + assert "observation_incomplete" in {item.code for item in declared_comparison.findings} assert declared_comparison.observed_capabilities == ["database_write"] @@ -775,6 +824,35 @@ def test_stale_trust_evidence_is_historical_not_current(tmp_path: Path) -> None: assert manifest.trust_source.evaluated_at != manifest.trust_source.snapshot_generated_at +def test_same_day_scan_uses_deterministic_end_of_day_freshness(tmp_path: Path) -> None: + repo = _repo(tmp_path) + endpoint = "https://example.invalid/mcp" + (repo / ".mcp.json").write_text( + json.dumps({"mcpServers": {"known": {"url": endpoint}}}), + encoding="utf-8", + ) + trust = _trust_fixture(tmp_path) + seed_path = trust / "src/mcp_trust/catalog/seed_servers.json" + seed = json.loads(seed_path.read_text()) + seed[0]["source"] = {"kind": "remote", "reference": endpoint} + seed_path.write_text(json.dumps(seed), encoding="utf-8") + snapshot_path = trust / "src/mcp_trust/catalog_snapshot.json" + snapshot = json.loads(snapshot_path.read_text()) + current = datetime.now(UTC).isoformat() + snapshot["generated_at"] = current + snapshot["servers"][0]["scanned_at"] = current + snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8") + _commit_trust_fixture(trust, "same-day trust scan") + + manifest = build_release_trust_manifest(repo, trust) + evidence = manifest.entries[0].evidence + + assert manifest.trust_source is not None + assert manifest.trust_source.evaluated_at.endswith("T23:59:59.999999+00:00") + assert evidence.state == "current" + assert evidence.network_isolation == "verified_none" + + @pytest.mark.parametrize( "generated_at", [ @@ -837,6 +915,102 @@ def test_unproven_network_isolation_cannot_be_current_trust_evidence(tmp_path: P assert "mcp-trust record does not prove network isolation" in evidence.unknown_reasons +def test_not_applicable_network_isolation_cannot_be_current_trust_evidence( + tmp_path: Path, +) -> None: + repo = _repo(tmp_path) + endpoint = "https://example.invalid/mcp" + (repo / ".mcp.json").write_text( + json.dumps({"mcpServers": {"known": {"url": endpoint}}}), + encoding="utf-8", + ) + trust = _trust_fixture(tmp_path) + seed_path = trust / "src/mcp_trust/catalog/seed_servers.json" + seed = json.loads(seed_path.read_text()) + seed[0]["source"] = {"kind": "remote", "reference": endpoint} + seed_path.write_text(json.dumps(seed), encoding="utf-8") + snapshot_path = trust / "src/mcp_trust/catalog_snapshot.json" + snapshot = json.loads(snapshot_path.read_text()) + snapshot["servers"][0]["scan_mode"] = "static-analysis" + snapshot["servers"][0]["sandbox"] = {"mode": "not_applicable", "network": "not_applicable"} + snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8") + _commit_trust_fixture(trust, "network isolation not applicable") + + evidence = build_release_trust_manifest(repo, trust).entries[0].evidence + + assert evidence.match_state == "exact" + assert evidence.version_alignment == "not_applicable" + assert evidence.network_isolation == "not_applicable" + assert evidence.state == "unverifiable" + assert "network isolation was not applicable to the recorded scan" in evidence.unknown_reasons + + +def test_ipv6_network_counters_are_observed(tmp_path: Path) -> None: + before = tmp_path / "network.before" + after = tmp_path / "network.after" + before6 = tmp_path / "network6.before" + after6 = tmp_path / "network6.after" + snmp = ( + "Ip: OutRequests\nIp: 0\n" + "Tcp: ActiveOpens PassiveOpens AttemptFails\nTcp: 0 0 0\n" + "Udp: OutDatagrams\nUdp: 0\n" + ) + before.write_text(snmp, encoding="utf-8") + after.write_text(snmp, encoding="utf-8") + before6.write_text( + "Ip6OutRequests 0\nUdp6OutDatagrams 0\n", + encoding="utf-8", + ) + after6.write_text( + "Ip6OutRequests 1\nUdp6OutDatagrams 1\n", + encoding="utf-8", + ) + + evidence = _network_evidence(before, after, before6, after6, timed_out=False) + + assert evidence.surface.complete is True + assert evidence.surface.attempted is True + assert evidence.counters["Ip6.OutRequests"] == 1 + assert evidence.counters["Udp6.OutDatagrams"] == 1 + + +def test_missing_ipv6_counters_make_network_observation_incomplete(tmp_path: Path) -> None: + before = tmp_path / "network.before" + after = tmp_path / "network.after" + before.write_text("Ip: OutRequests\nIp: 0\n", encoding="utf-8") + after.write_text("Ip: OutRequests\nIp: 0\n", encoding="utf-8") + + evidence = _network_evidence( + before, + after, + tmp_path / "missing-network6.before", + tmp_path / "missing-network6.after", + timed_out=False, + ) + + assert evidence.surface.complete is False + assert evidence.surface.attempted is None + + +@requires_docker +def test_ipv6_udp_attempt_is_detected_and_blocked(tmp_path: Path) -> None: + repo = _repo(tmp_path) + code = ( + "const dgram=require('dgram');const socket=dgram.createSocket('udp6');" + "socket.send(Buffer.from('x'),9,'::1',error=>{socket.close();if(error)process.exit(2)})" + ) + + observation = observe_command(repo, ["node", "-e", code], image="node:24-slim") + + assert observation.command.exit_code == 0 + assert observation.network.surface.complete is True + assert observation.network.surface.attempted is True + assert observation.network.counters["Udp6.OutDatagrams"] > 0 + comparison = compare_bill(_declaration(), observation) + assert comparison.verdict == "block" + assert "undeclared_network_attempt" in {item.code for item in comparison.findings} + + def test_dirty_trust_source_cannot_emit_authoritative_grade_details(tmp_path: Path) -> None: repo = _repo(tmp_path) (repo / ".mcp.json").write_text( @@ -1311,10 +1485,10 @@ def test_cli_inspect_and_verify_the_portable_capsule(tmp_path: Path) -> None: "process.exit(0)", ], ) - assert inspected.exit_code == 0, inspected.output + assert inspected.exit_code == 1, inspected.output receipt = json.loads(inspected.output) - assert receipt["ok"] is True - assert receipt["verdict"] == "pass" + assert receipt["ok"] is False + assert receipt["verdict"] == "unknown" verified = runner.invoke( main, [ From 3dda3842d92268afcef9550a27cb655db050ac71 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 06:56:01 -0700 Subject: [PATCH 25/41] Reject contradictory trust isolation evidence --- CHANGELOG.md | 3 ++- docs/PROOF-BEFORE-ACTION.md | 2 ++ src/mcp_audit/proof_trust.py | 8 ++++---- tests/test_proof_before_action.py | 15 ++++++++++++--- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7345ae5..dec6ec8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 closed before a record can remain current. Filesystem and database final-state snapshots now report incomplete for transient-attempt coverage, IPv6 counters join IPv4 network evidence, and same-day trust freshness uses a deterministic - end-of-day bound. + end-of-day bound. Contradictory `not_applicable` sandbox/network records cannot + satisfy network-isolation proof. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index 06db557..6cd75a6 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -152,6 +152,8 @@ local trust source UNKNOWN instead of being coerced into evidence. Snapshot generation time must be a valid, non-future, timezone-aware timestamp that is not earlier than any contained scan. A record without proven network isolation cannot be `current`, even when its dependency match is otherwise exact. +Network-off evidence requires a Docker sandbox with `network: none`; contradictory +`not_applicable` mode/network combinations remain unproven. Freshness is evaluated at the deterministic end of the current UTC date, recorded separately from the snapshot generation timestamp. Runs are byte-stable diff --git a/src/mcp_audit/proof_trust.py b/src/mcp_audit/proof_trust.py index e762452..760138e 100644 --- a/src/mcp_audit/proof_trust.py +++ b/src/mcp_audit/proof_trust.py @@ -667,12 +667,12 @@ def _match_dependency( unknowns.append("evidence is not bound to an exact dependency version") sandbox = cast(dict[str, Any], record["sandbox"]) network: Literal["verified_none", "unknown", "not_applicable"] = ( - "verified_none" + "not_applicable" + if sandbox.get("mode") == "not_applicable" + else "verified_none" if record.get("scan_mode") == "mcpaudit-local-network-off" - and isinstance(sandbox, dict) + and sandbox.get("mode") == "docker" and sandbox.get("network") == "none" - else "not_applicable" - if isinstance(sandbox, dict) and sandbox.get("mode") == "not_applicable" else "unknown" ) if network != "verified_none": diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index e519279..a162a05 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -915,8 +915,17 @@ def test_unproven_network_isolation_cannot_be_current_trust_evidence(tmp_path: P assert "mcp-trust record does not prove network isolation" in evidence.unknown_reasons -def test_not_applicable_network_isolation_cannot_be_current_trust_evidence( +@pytest.mark.parametrize( + ("scan_mode", "network"), + [ + ("static-analysis", "not_applicable"), + ("mcpaudit-local-network-off", "none"), + ], +) +def test_not_applicable_or_contradictory_network_isolation_is_not_current( tmp_path: Path, + scan_mode: str, + network: str, ) -> None: repo = _repo(tmp_path) endpoint = "https://example.invalid/mcp" @@ -931,8 +940,8 @@ def test_not_applicable_network_isolation_cannot_be_current_trust_evidence( seed_path.write_text(json.dumps(seed), encoding="utf-8") snapshot_path = trust / "src/mcp_trust/catalog_snapshot.json" snapshot = json.loads(snapshot_path.read_text()) - snapshot["servers"][0]["scan_mode"] = "static-analysis" - snapshot["servers"][0]["sandbox"] = {"mode": "not_applicable", "network": "not_applicable"} + snapshot["servers"][0]["scan_mode"] = scan_mode + snapshot["servers"][0]["sandbox"] = {"mode": "not_applicable", "network": network} snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8") _commit_trust_fixture(trust, "network isolation not applicable") From fa253e97e0700cc6454f7b78d5f300b0539e5ea9 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 07:07:29 -0700 Subject: [PATCH 26/41] Bind observations to immutable evidence --- CHANGELOG.md | 4 +++- docs/PROOF-BEFORE-ACTION.md | 7 ++++++- src/mcp_audit/proof_capsule.py | 22 ++++++++++++++++++++++ src/mcp_audit/proof_observer.py | 9 ++++++--- tests/test_proof_before_action.py | 21 +++++++++++++++++++++ 5 files changed, 58 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dec6ec8..87dc6c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 snapshots now report incomplete for transient-attempt coverage, IPv6 counters join IPv4 network evidence, and same-day trust freshness uses a deterministic end-of-day bound. Contradictory `not_applicable` sandbox/network records cannot - satisfy network-isolation proof. + satisfy network-isolation proof. Docker staging is bound to the initially + resolved immutable image ID, and complete surfaces with unknown state fields + remain non-passing. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index 6cd75a6..048bfb2 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -30,7 +30,8 @@ limitations: [] ``` Ensure the selected container image already exists locally. Proof Before Action -will not pull it. Then inspect: +will not pull it. The mutable reference is resolved once and every subsequent +tool check and staging step uses that immutable image ID. Then inspect: ```console proof-before-action inspect \ @@ -126,6 +127,10 @@ requested destination. Missing or regressed required counters make the network surface incomplete. Link or special-file output blocks collection rather than silently disappearing. The command cannot write the observer-owned evidence tmpfs. + +Comparison also treats any schema-valid `complete: true` surface that retains an +unknown attempted, decision, outcome, or persisted state as `unknown`. Legacy or +alternate producers cannot use a completeness flag alone to manufacture `pass`. Command output is redirected there by PID 1 under an OS file-size limit before it is hashed and omitted. diff --git a/src/mcp_audit/proof_capsule.py b/src/mcp_audit/proof_capsule.py index 93ac354..1213e3d 100644 --- a/src/mcp_audit/proof_capsule.py +++ b/src/mcp_audit/proof_capsule.py @@ -151,6 +151,28 @@ def compare_bill(declaration: ActionDeclaration, observation: Observation) -> Bi message="one or more requested observation surfaces were incomplete", ) ) + surfaces = ( + observation.filesystem, + observation.database, + observation.network.surface, + ) + if any( + surface.complete + and ( + surface.attempted is None + or surface.decision == "unknown" + or surface.outcome == "unknown" + or surface.persisted == "unknown" + ) + for surface in surfaces + ): + findings.append( + ComparisonFinding( + code="observation_state_unknown", + severity="unknown", + message="a completed observation surface retained an unknown state", + ) + ) verdict: Literal["pass", "block", "unknown"] = ( "block" if any(item.severity == "error" for item in findings) diff --git a/src/mcp_audit/proof_observer.py b/src/mcp_audit/proof_observer.py index 2469239..a140413 100644 --- a/src/mcp_audit/proof_observer.py +++ b/src/mcp_audit/proof_observer.py @@ -195,7 +195,7 @@ def observe_command( before_databases = _database_snapshot(staged) _make_disposable_writable(staged) image_id = _local_image_id(image) - _require_image_tools(image) + _require_image_tools(image_id) name = "pba-" + secrets.token_hex(8) runtime_image = name + "-input" stage_create = _run( @@ -208,7 +208,7 @@ def observe_command( "none", "--entrypoint", "/bin/true", - image, + image_id, ], timeout=20, ) @@ -231,6 +231,9 @@ def observe_command( raise ObservationBlocked( "content-addressed staging image failed: " + _safe_error(committed.stderr) ) + committed_image_id = committed.stdout.decode().strip() + if not committed_image_id.startswith("sha256:"): + raise ObservationBlocked("content-addressed staging image did not return an immutable ID") if error := _cleanup_docker_resource(["docker", "rm", "-f", staging_container_id], timeout=20): raise ObservationBlocked("staging container cleanup could not be confirmed: " + error) staging_container_id = None @@ -283,7 +286,7 @@ def observe_command( f"PBA_TIMEOUT_SECONDS={timeout_seconds}", "--entrypoint", "/bin/sh", - runtime_image, + committed_image_id, "-c", _WRAPPER, "proof-before-action-wrapper", diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index a162a05..3736d8f 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -313,6 +313,14 @@ def test_read_only_final_state_is_unknown_and_deterministic(tmp_path: Path) -> N assert first.file_changes == [] assert first.database_changes == [] assert first.network.surface.attempted is False + expected_image_id = subprocess.run( + ["docker", "image", "inspect", "--format", "{{.Id}}", "node:24-slim"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + assert first.isolation.image_reference == "node:24-slim" + assert first.isolation.image_id == expected_image_id first_comparison = compare_bill(_declaration(), first) second_comparison = compare_bill(_declaration(), second) assert first_comparison.verdict == "unknown" @@ -712,6 +720,19 @@ def test_declaration_omission_is_deterministic() -> None: assert first.verdict == "block" assert canonical_json_bytes(first) == canonical_json_bytes(second) + clean_unknown = observation.model_copy( + update={ + "filesystem": unchanged, + "file_changes": [], + "database": unchanged, + "database_changes": [], + "network": NetworkEvidence(surface=unchanged), + } + ) + unknown_comparison = compare_bill(_declaration(), clean_unknown) + assert unknown_comparison.verdict == "unknown" + assert "observation_state_unknown" in {item.code for item in unknown_comparison.findings} + def _trust_fixture(tmp_path: Path) -> Path: trust = tmp_path / "mcp-trust" From 5b0151cbba12ea920c5d49ac2a2d76b227c4e614 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 07:18:59 -0700 Subject: [PATCH 27/41] Fix build hooks and surface consistency --- CHANGELOG.md | 4 +- docs/PROOF-BEFORE-ACTION.md | 2 + mcp_audit_build_backend.py | 12 +++--- src/mcp_audit/proof_capsule.py | 27 +++++++++++++ tests/test_proof_before_action.py | 64 +++++++++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87dc6c9..4233b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 end-of-day bound. Contradictory `not_applicable` sandbox/network records cannot satisfy network-isolation proof. Docker staging is bound to the initially resolved immutable image ID, and complete surfaces with unknown state fields - remain non-passing. + remain non-passing. Contradictory complete-surface fields also remain + non-passing, and the custom PEP 517 requirement hooks delegate correctly to + uv-build instead of recursing. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index 048bfb2..8c7797f 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -131,6 +131,8 @@ tmpfs. Comparison also treats any schema-valid `complete: true` surface that retains an unknown attempted, decision, outcome, or persisted state as `unknown`. Legacy or alternate producers cannot use a completeness flag alone to manufacture `pass`. +Contradictory combinations such as `attempted: false` with +`decision: allowed`/`outcome: succeeded` are likewise non-passing. Command output is redirected there by PID 1 under an OS file-size limit before it is hashed and omitted. diff --git a/mcp_audit_build_backend.py b/mcp_audit_build_backend.py index f7830e3..ff7b882 100644 --- a/mcp_audit_build_backend.py +++ b/mcp_audit_build_backend.py @@ -56,25 +56,25 @@ def build_editable( def get_requires_for_build_sdist( config_settings: Mapping[Any, Any] | None = None, ) -> list[str]: - from uv_build import get_requires_for_build_sdist + from uv_build import get_requires_for_build_sdist as uv_get_requires_for_build_sdist - return list(get_requires_for_build_sdist(config_settings)) + return list(uv_get_requires_for_build_sdist(config_settings)) def get_requires_for_build_wheel( config_settings: Mapping[Any, Any] | None = None, ) -> list[str]: - from uv_build import get_requires_for_build_wheel + from uv_build import get_requires_for_build_wheel as uv_get_requires_for_build_wheel - return list(get_requires_for_build_wheel(config_settings)) + return list(uv_get_requires_for_build_wheel(config_settings)) def get_requires_for_build_editable( config_settings: Mapping[Any, Any] | None = None, ) -> list[str]: - from uv_build import get_requires_for_build_editable + from uv_build import get_requires_for_build_editable as uv_get_requires_for_build_editable - return list(get_requires_for_build_editable(config_settings)) + return list(uv_get_requires_for_build_editable(config_settings)) def prepare_metadata_for_build_wheel( diff --git a/src/mcp_audit/proof_capsule.py b/src/mcp_audit/proof_capsule.py index 1213e3d..af2e886 100644 --- a/src/mcp_audit/proof_capsule.py +++ b/src/mcp_audit/proof_capsule.py @@ -26,6 +26,7 @@ Observation, ProducerEvidence, ReleaseTrustManifest, + SurfaceObservation, canonical_json_bytes, sha256_bytes, ) @@ -173,6 +174,14 @@ def compare_bill(declaration: ActionDeclaration, observation: Observation) -> Bi message="a completed observation surface retained an unknown state", ) ) + if any(surface.complete and _surface_state_is_contradictory(surface) for surface in surfaces): + findings.append( + ComparisonFinding( + code="observation_state_contradictory", + severity="unknown", + message="a completed observation surface contained contradictory state fields", + ) + ) verdict: Literal["pass", "block", "unknown"] = ( "block" if any(item.severity == "error" for item in findings) @@ -190,6 +199,24 @@ def compare_bill(declaration: ActionDeclaration, observation: Observation) -> Bi ) +def _surface_state_is_contradictory(surface: SurfaceObservation) -> bool: + if surface.attempted is False: + return ( + surface.decision != "not_applicable" + or surface.outcome != "not_applicable" + or surface.persisted != "unchanged" + ) + if surface.attempted is True and ( + surface.decision == "not_applicable" or surface.outcome == "not_applicable" + ): + return True + if (surface.decision == "not_applicable") != (surface.outcome == "not_applicable"): + return True + if surface.decision == "blocked" and surface.outcome == "succeeded": + return True + return surface.persisted == "changed" and surface.attempted is not True + + def build_capsule( declaration: ActionDeclaration, observation: Observation, diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index 3736d8f..61f03f8 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -8,14 +8,17 @@ import shutil import sqlite3 import subprocess +import sys from collections.abc import Callable, Iterator from datetime import UTC, datetime from pathlib import Path +from types import ModuleType import pytest from click.testing import CliRunner import mcp_audit.proof_capsule as proof_capsule_module +import mcp_audit_build_backend as build_backend from mcp_audit.proof_capsule import ( build_capsule, compare_bill, @@ -733,6 +736,67 @@ def test_declaration_omission_is_deterministic() -> None: assert unknown_comparison.verdict == "unknown" assert "observation_state_unknown" in {item.code for item in unknown_comparison.findings} + contradictory = clean_unknown.model_copy( + update={ + "filesystem": unchanged.model_copy( + update={ + "attempted": False, + "decision": "allowed", + "outcome": "succeeded", + } + ), + "database": unchanged.model_copy( + update={ + "attempted": False, + "decision": "not_applicable", + "outcome": "not_applicable", + } + ), + "network": NetworkEvidence( + surface=unchanged.model_copy( + update={ + "attempted": False, + "decision": "not_applicable", + "outcome": "not_applicable", + } + ) + ), + } + ) + contradictory_comparison = compare_bill(_declaration(), contradictory) + assert contradictory_comparison.verdict == "unknown" + assert "observation_state_contradictory" in {item.code for item in contradictory_comparison.findings} + + +def test_build_requirement_hooks_delegate_to_uv_build( + monkeypatch: pytest.MonkeyPatch, +) -> None: + uv_build = ModuleType("uv_build") + calls: list[tuple[str, object]] = [] + for hook in ("sdist", "wheel", "editable"): + name = f"get_requires_for_build_{hook}" + + def requirement_hook( + config_settings: object, + *, + hook_name: str = name, + ) -> list[str]: + calls.append((hook_name, config_settings)) + return [hook_name] + + monkeypatch.setattr(uv_build, name, requirement_hook, raising=False) + monkeypatch.setitem(sys.modules, "uv_build", uv_build) + settings = {"proof": "before-action"} + + assert build_backend.get_requires_for_build_sdist(settings) == ["get_requires_for_build_sdist"] + assert build_backend.get_requires_for_build_wheel(settings) == ["get_requires_for_build_wheel"] + assert build_backend.get_requires_for_build_editable(settings) == ["get_requires_for_build_editable"] + assert calls == [ + ("get_requires_for_build_sdist", settings), + ("get_requires_for_build_wheel", settings), + ("get_requires_for_build_editable", settings), + ] + def _trust_fixture(tmp_path: Path) -> Path: trust = tmp_path / "mcp-trust" From 683de4d5a3c811bc2f6e36f8ffa020ec6e51799e Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 07:39:39 -0700 Subject: [PATCH 28/41] Fail closed on malformed trust evidence --- CHANGELOG.md | 5 +- src/mcp_audit/proof_models.py | 14 +++- src/mcp_audit/proof_trust.py | 127 +++++++++++++++++++++++++----- tests/test_proof_before_action.py | 120 ++++++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4233b63..6e50910 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,7 +46,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 resolved immutable image ID, and complete surfaces with unknown state fields remain non-passing. Contradictory complete-surface fields also remain non-passing, and the custom PEP 517 requirement hooks delegate correctly to - uv-build instead of recursing. + uv-build instead of recursing. Contradictory trust state/match/authority + combinations are rejected, while malformed package, Python, and registry + dependency-manifest shapes produce partial discovery diagnostics rather than + tracebacks or falsely complete coverage. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/src/mcp_audit/proof_models.py b/src/mcp_audit/proof_models.py index 426ad79..0070af2 100644 --- a/src/mcp_audit/proof_models.py +++ b/src/mcp_audit/proof_models.py @@ -238,11 +238,23 @@ class TrustEvidence(StrictModel): unknown_reasons: list[str] = Field(default_factory=list) @model_validator(mode="after") - def masked_records_withhold_details(self) -> TrustEvidence: + def enforce_cross_field_consistency(self) -> TrustEvidence: if self.state == "masked" and any( value is not None for value in (self.grade, self.transparency, self.scanned_at, self.engine) ): raise ValueError("masked trust evidence must not expose withheld scan details") + if self.state in {"current", "stale"} and self.match_state != "exact": + raise ValueError("current or stale trust evidence requires an exact match") + if self.state == "current" and self.network_isolation != "verified_none": + raise ValueError("current trust evidence requires verified network isolation") + if self.state == "current" and self.version_alignment not in {"exact", "not_applicable"}: + raise ValueError("current trust evidence requires authoritative version alignment") + if self.state == "masked" and self.match_state != "exact": + raise ValueError("masked trust evidence requires an exact match") + if self.state == "unmatched" and self.match_state != "unmatched": + raise ValueError("unmatched trust evidence requires an unmatched match state") + if self.state == "ambiguous" and self.match_state != "ambiguous": + raise ValueError("ambiguous trust evidence requires an ambiguous match state") return self diff --git a/src/mcp_audit/proof_trust.py b/src/mcp_audit/proof_trust.py index 760138e..ea03880 100644 --- a/src/mcp_audit/proof_trust.py +++ b/src/mcp_audit/proof_trust.py @@ -134,22 +134,53 @@ def discover_repo_mcp( if package_json.is_file(): try: package_payload = json.loads(package_json.read_text(encoding="utf-8")) - for section in ("dependencies", "devDependencies", "optionalDependencies"): - values = package_payload.get(section, {}) - if not isinstance(values, dict): - continue - for name, version in values.items(): - if "mcp" not in str(name).lower(): + if not isinstance(package_payload, dict): + diagnostics.append( + DiscoveryDiagnostic( + source_path="package.json", + source_pointer="/", + code="invalid_manifest", + message="package manifest must be an object", + ) + ) + else: + for section in ("dependencies", "devDependencies", "optionalDependencies"): + if section not in package_payload: continue - dependencies.append( - _package_occurrence( - "package.json", - f"/{section}/{_json_pointer(str(name))}", - str(name), - str(version), - "npm", + values = package_payload[section] + if not isinstance(values, dict): + diagnostics.append( + DiscoveryDiagnostic( + source_path="package.json", + source_pointer=f"/{section}", + code="invalid_manifest", + message=f"{section} must be an object", + ) + ) + continue + for name, version in values.items(): + if "mcp" not in str(name).lower(): + continue + pointer = f"/{section}/{_json_pointer(str(name))}" + if not isinstance(version, str): + diagnostics.append( + DiscoveryDiagnostic( + source_path="package.json", + source_pointer=pointer, + code="invalid_manifest", + message="package dependency version must be a string", + ) + ) + continue + dependencies.append( + _package_occurrence( + "package.json", + pointer, + str(name), + version, + "npm", + ) ) - ) except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: diagnostics.append( DiscoveryDiagnostic( @@ -163,11 +194,46 @@ def discover_repo_mcp( pyproject = repo / "pyproject.toml" if pyproject.is_file(): try: - project = tomllib.loads(pyproject.read_text(encoding="utf-8")).get("project", {}) - for index, spec in enumerate(project.get("dependencies", [])): - if "mcp" not in str(spec).lower(): + pyproject_payload = tomllib.loads(pyproject.read_text(encoding="utf-8")) + project = pyproject_payload.get("project", {}) + if not isinstance(project, dict): + diagnostics.append( + DiscoveryDiagnostic( + source_path="pyproject.toml", + source_pointer="/project", + code="invalid_manifest", + message="project must be a table", + ) + ) + project_dependencies: list[Any] = [] + else: + dependencies_value = project.get("dependencies", []) + if not isinstance(dependencies_value, list): + diagnostics.append( + DiscoveryDiagnostic( + source_path="pyproject.toml", + source_pointer="/project/dependencies", + code="invalid_manifest", + message="project dependencies must be an array", + ) + ) + project_dependencies = [] + else: + project_dependencies = dependencies_value + for index, spec in enumerate(project_dependencies): + if not isinstance(spec, str): + diagnostics.append( + DiscoveryDiagnostic( + source_path="pyproject.toml", + source_pointer=f"/project/dependencies/{index}", + code="invalid_manifest", + message="project dependency must be a string", + ) + ) continue - name, version, exact = _parse_pypi_spec(str(spec)) + if "mcp" not in spec.lower(): + continue + name, version, exact = _parse_pypi_spec(spec) dependencies.append( _occurrence( source_path="pyproject.toml", @@ -196,7 +262,30 @@ def discover_repo_mcp( if descriptor.is_file(): try: payload = json.loads(descriptor.read_text(encoding="utf-8")) - packages = payload.get("packages", []) if isinstance(payload, dict) else [] + if not isinstance(payload, dict): + diagnostics.append( + DiscoveryDiagnostic( + source_path="server.json", + source_pointer="/", + code="invalid_manifest", + message="MCP registry descriptor must be an object", + ) + ) + packages: list[Any] = [] + else: + packages_value = payload.get("packages", []) + if not isinstance(packages_value, list): + diagnostics.append( + DiscoveryDiagnostic( + source_path="server.json", + source_pointer="/packages", + code="invalid_manifest", + message="packages must be an array", + ) + ) + packages = [] + else: + packages = packages_value for index, package in enumerate(packages): if not isinstance(package, dict): diagnostics.append( diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index 61f03f8..2984597 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -32,6 +32,7 @@ Observation, ReleaseTrustManifest, SubjectSnapshotEvidence, + TrustEvidence, canonical_json_bytes, sha256_bytes, ) @@ -587,6 +588,125 @@ def test_discovery_preserves_the_selected_server_map_pointer( assert manifest.diagnostics[0].source_pointer == "/servers/broken" +@pytest.mark.parametrize( + "section", + ["dependencies", "devDependencies", "optionalDependencies"], +) +def test_non_object_package_dependency_sections_are_partial( + tmp_path: Path, + section: str, +) -> None: + repo = _repo(tmp_path) + (repo / "package.json").write_text(json.dumps({section: ["@fixture/mcp"]}), encoding="utf-8") + + manifest = build_release_trust_manifest(repo, None) + + assert manifest.discovery_coverage == "partial" + assert manifest.dependencies == [] + assert [(item.source_pointer, item.code) for item in manifest.diagnostics] == [ + (f"/{section}", "invalid_manifest") + ] + + +@pytest.mark.parametrize( + ("contents", "pointer"), + [ + ("project = []\n", "/project"), + ('[project]\ndependencies = "mcp"\n', "/project/dependencies"), + ("[project]\ndependencies = [1]\n", "/project/dependencies/0"), + ], +) +def test_malformed_pyproject_dependency_shapes_are_partial( + tmp_path: Path, + contents: str, + pointer: str, +) -> None: + repo = _repo(tmp_path) + (repo / "pyproject.toml").write_text(contents, encoding="utf-8") + + manifest = build_release_trust_manifest(repo, None) + + assert manifest.discovery_coverage == "partial" + assert manifest.dependencies == [] + assert [(item.source_pointer, item.code) for item in manifest.diagnostics] == [ + (pointer, "invalid_manifest") + ] + + +@pytest.mark.parametrize( + ("state", "match_state"), + [ + ("current", "unmatched"), + ("stale", "ambiguous"), + ("unmatched", "exact"), + ("ambiguous", "exact"), + ("masked", "unmatched"), + ], +) +def test_trust_evidence_rejects_contradictory_state_and_match( + state: str, + match_state: str, +) -> None: + with pytest.raises(ValueError, match="trust evidence"): + TrustEvidence.model_validate({"state": state, "match_state": match_state}) + + +@pytest.mark.parametrize( + "updates", + [ + {"network_isolation": "unknown", "version_alignment": "not_applicable"}, + {"network_isolation": "verified_none", "version_alignment": "dependency_unresolved"}, + ], +) +def test_current_trust_evidence_requires_complete_authority( + updates: dict[str, str], +) -> None: + with pytest.raises(ValueError, match="current trust evidence"): + TrustEvidence.model_validate( + { + "state": "current", + "match_state": "exact", + **updates, + } + ) + + +def test_non_string_package_dependency_version_is_partial(tmp_path: Path) -> None: + repo = _repo(tmp_path) + (repo / "package.json").write_text( + json.dumps({"dependencies": {"@fixture/mcp": {"version": "1.0.0"}}}), + encoding="utf-8", + ) + + manifest = build_release_trust_manifest(repo, None) + + assert manifest.discovery_coverage == "partial" + assert manifest.dependencies == [] + assert manifest.diagnostics[0].source_pointer == "/dependencies/@fixture~1mcp" + + +@pytest.mark.parametrize( + ("payload", "pointer"), + [ + ([], "/"), + ({"packages": {}}, "/packages"), + ], +) +def test_malformed_server_descriptor_root_shapes_are_partial( + tmp_path: Path, + payload: object, + pointer: str, +) -> None: + repo = _repo(tmp_path) + (repo / "server.json").write_text(json.dumps(payload), encoding="utf-8") + + manifest = build_release_trust_manifest(repo, None) + + assert manifest.discovery_coverage == "partial" + assert manifest.dependencies == [] + assert manifest.diagnostics[0].source_pointer == pointer + + def test_ignored_staged_subject_input_marks_the_commit_unbound( tmp_path: Path, ) -> None: From 558824033395a63f24efe68602791243181b8411 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 07:54:28 -0700 Subject: [PATCH 29/41] Bind trust evidence to complete occurrences --- CHANGELOG.md | 5 +- src/mcp_audit/proof_models.py | 50 +++++++++++-- src/mcp_audit/proof_trust.py | 104 ++++++++++++++++++++++++--- tests/test_proof_before_action.py | 114 +++++++++++++++++++++++++++++- 4 files changed, 257 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e50910..8b3303a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,7 +49,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 uv-build instead of recursing. Contradictory trust state/match/authority combinations are rejected, while malformed package, Python, and registry dependency-manifest shapes produce partial discovery diagnostics rather than - tracebacks or falsely complete coverage. + tracebacks or falsely complete coverage. Registry scalar fields no longer + coerce malformed values, trust entries bind the complete unique dependency + occurrence, `current` evidence requires a complete authoritative scan record, + and masked evidence withholds every scan detail. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/src/mcp_audit/proof_models.py b/src/mcp_audit/proof_models.py index 0070af2..22b9957 100644 --- a/src/mcp_audit/proof_models.py +++ b/src/mcp_audit/proof_models.py @@ -4,6 +4,7 @@ import hashlib import json +from datetime import datetime from enum import StrEnum from typing import Any, Final, Literal @@ -239,9 +240,15 @@ class TrustEvidence(StrictModel): @model_validator(mode="after") def enforce_cross_field_consistency(self) -> TrustEvidence: - if self.state == "masked" and any( - value is not None for value in (self.grade, self.transparency, self.scanned_at, self.engine) - ): + scan_details = ( + self.grade, + self.transparency, + self.scanned_at, + self.engine, + self.engine_version, + self.scan_mode, + ) + if self.state == "masked" and any(value is not None for value in scan_details): raise ValueError("masked trust evidence must not expose withheld scan details") if self.state in {"current", "stale"} and self.match_state != "exact": raise ValueError("current or stale trust evidence requires an exact match") @@ -255,6 +262,28 @@ def enforce_cross_field_consistency(self) -> TrustEvidence: raise ValueError("unmatched trust evidence requires an unmatched match state") if self.state == "ambiguous" and self.match_state != "ambiguous": raise ValueError("ambiguous trust evidence requires an ambiguous match state") + if self.match_state != "exact" and any(value is not None for value in scan_details): + raise ValueError("non-exact trust evidence must not expose scan details") + if self.state in {"current", "stale"}: + if not self.slug or not all(isinstance(value, str) and bool(value) for value in scan_details): + raise ValueError("current or stale trust evidence requires a complete scan record") + if self.scanned_at is None: + raise ValueError("current or stale trust evidence requires a scan timestamp") + try: + scanned_at = datetime.fromisoformat(self.scanned_at.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError("trust evidence scan timestamp must be valid") from exc + if scanned_at.tzinfo is None: + raise ValueError("trust evidence scan timestamp must be timezone-aware") + if self.state == "current" and self.unknown_reasons: + raise ValueError("current trust evidence must not retain unknown reasons") + if self.state in {"masked", "unmatched", "unverifiable", "ambiguous"} and not (self.unknown_reasons): + raise ValueError("non-authoritative trust evidence requires an unknown reason") + if self.state == "unverifiable" and any(value is not None for value in scan_details): + if not self.slug or not all(isinstance(value, str) and bool(value) for value in scan_details): + raise ValueError( + "unverifiable trust evidence must expose either a complete scan record or none" + ) return self @@ -290,10 +319,19 @@ class ReleaseTrustManifest(StrictModel): @model_validator(mode="after") def every_dependency_has_one_entry(self) -> ReleaseTrustManifest: - dependency_ids = [item.dependency_id for item in self.dependencies] - entry_ids = [item.dependency.dependency_id for item in self.entries] - if sorted(dependency_ids) != sorted(entry_ids): + dependencies_by_id = {item.dependency_id: item for item in self.dependencies} + entries_by_id = {item.dependency.dependency_id: item for item in self.entries} + if len(dependencies_by_id) != len(self.dependencies): + raise ValueError("dependency occurrence IDs must be unique") + if len(entries_by_id) != len(self.entries): + raise ValueError("trust entry dependency IDs must be unique") + if dependencies_by_id.keys() != entries_by_id.keys(): raise ValueError("every dependency occurrence must have exactly one trust entry") + if any( + entry.dependency != dependencies_by_id[dependency_id] + for dependency_id, entry in entries_by_id.items() + ): + raise ValueError("every trust entry must bind the full dependency occurrence") return self diff --git a/src/mcp_audit/proof_trust.py b/src/mcp_audit/proof_trust.py index ea03880..cae500f 100644 --- a/src/mcp_audit/proof_trust.py +++ b/src/mcp_audit/proof_trust.py @@ -286,6 +286,19 @@ def discover_repo_mcp( packages = [] else: packages = packages_value + descriptor_name_value = payload.get("name") + if "name" in payload and ( + not isinstance(descriptor_name_value, str) or not descriptor_name_value + ): + diagnostics.append( + DiscoveryDiagnostic( + source_path="server.json", + source_pointer="/name", + code="invalid_manifest", + message="descriptor name must be a non-empty string", + ) + ) + packages = [] for index, package in enumerate(packages): if not isinstance(package, dict): diagnostics.append( @@ -297,34 +310,109 @@ def discover_repo_mcp( ) ) continue - registry = str(package.get("registryType", "unknown")) + pointer = f"/packages/{index}" + invalid = False + registry_value = package.get("registryType") + if not isinstance(registry_value, str) or not registry_value: + diagnostics.append( + DiscoveryDiagnostic( + source_path="server.json", + source_pointer=f"{pointer}/registryType", + code="invalid_entry", + message="package registryType must be a non-empty string", + ) + ) + invalid = True + registry = "unknown" + else: + registry = registry_value kind = "npm" if registry == "npm" else "pypi" if registry == "pypi" else "unknown" - name = str(package.get("identifier", "")) - version = str(package.get("version", "")) or None + identifier_value = package.get("identifier") + if not isinstance(identifier_value, str) or not identifier_value: + diagnostics.append( + DiscoveryDiagnostic( + source_path="server.json", + source_pointer=f"{pointer}/identifier", + code="invalid_entry", + message="package identifier must be a non-empty string", + ) + ) + invalid = True + name = "" + else: + name = identifier_value + version_value = package.get("version") + if "version" in package and (not isinstance(version_value, str) or not version_value): + diagnostics.append( + DiscoveryDiagnostic( + source_path="server.json", + source_pointer=f"{pointer}/version", + code="invalid_entry", + message="package version must be a non-empty string", + ) + ) + invalid = True + version = None + else: + version = version_value + runtime_hint_value = package.get("runtimeHint") + if "runtimeHint" in package and ( + not isinstance(runtime_hint_value, str) or not runtime_hint_value + ): + diagnostics.append( + DiscoveryDiagnostic( + source_path="server.json", + source_pointer=f"{pointer}/runtimeHint", + code="invalid_entry", + message="package runtimeHint must be a non-empty string", + ) + ) + invalid = True + runtime_hint = None + else: + runtime_hint = runtime_hint_value transport_payload = package.get("transport", {}) if isinstance(transport_payload, dict): - transport = str(transport_payload.get("type", "unknown")) + transport_value = transport_payload.get("type") + if "type" in transport_payload and ( + not isinstance(transport_value, str) or not transport_value + ): + diagnostics.append( + DiscoveryDiagnostic( + source_path="server.json", + source_pointer=f"{pointer}/transport/type", + code="invalid_entry", + message="package transport type must be a non-empty string", + ) + ) + invalid = True + transport = "unknown" + else: + transport = transport_value or "unknown" else: diagnostics.append( DiscoveryDiagnostic( source_path="server.json", - source_pointer=f"/packages/{index}/transport", + source_pointer=f"{pointer}/transport", code="invalid_entry", message="package transport must be an object", ) ) + invalid = True transport = "unknown" + if invalid: + continue dependencies.append( _occurrence( source_path="server.json", - source_pointer=f"/packages/{index}", - config_name=str(payload.get("name", name)), + source_pointer=pointer, + config_name=payload.get("name", name), transport=transport, identity_kind=kind, identity_name=_normalize_package(name, kind), requested_version=version, exact=bool(version and _EXACT_VERSION.fullmatch(version)), - command_basename=str(package.get("runtimeHint", "")) or None, + command_basename=runtime_hint, args=[], ) ) diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index 2984597..b8f060d 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -550,7 +550,7 @@ def test_server_descriptor_scalar_transport_is_a_partial_diagnostic( ) manifest = build_release_trust_manifest(repo, None) assert manifest.discovery_coverage == "partial" - assert manifest.dependencies[0].transport == "unknown" + assert manifest.dependencies == [] assert [(item.source_pointer, item.code, item.message) for item in manifest.diagnostics] == [ ( "/packages/0/transport", @@ -671,6 +671,52 @@ def test_current_trust_evidence_requires_complete_authority( ) +def test_current_trust_evidence_requires_a_complete_scan_record() -> None: + with pytest.raises(ValueError, match="complete scan record"): + TrustEvidence.model_validate( + { + "state": "current", + "match_state": "exact", + "network_isolation": "verified_none", + "version_alignment": "exact", + } + ) + + +def test_current_trust_evidence_rejects_unknown_reasons() -> None: + with pytest.raises(ValueError, match="must not retain unknown reasons"): + TrustEvidence.model_validate( + { + "state": "current", + "match_state": "exact", + "slug": "fixture", + "grade": "A", + "transparency": "high", + "scanned_at": "2026-07-19T00:00:00+00:00", + "engine": "mcpaudit", + "engine_version": "2.4.0", + "scan_mode": "mcpaudit-local-network-off", + "network_isolation": "verified_none", + "version_alignment": "exact", + "unknown_reasons": ["contradiction"], + } + ) + + +@pytest.mark.parametrize("field", ["engine_version", "scan_mode"]) +def test_masked_trust_evidence_withholds_every_scan_detail(field: str) -> None: + with pytest.raises(ValueError, match="masked trust evidence"): + TrustEvidence.model_validate( + { + "state": "masked", + "match_state": "exact", + "slug": "fixture", + field: "private", + "unknown_reasons": ["operator-masked evidence is intentionally withheld"], + } + ) + + def test_non_string_package_dependency_version_is_partial(tmp_path: Path) -> None: repo = _repo(tmp_path) (repo / "package.json").write_text( @@ -707,6 +753,72 @@ def test_malformed_server_descriptor_root_shapes_are_partial( assert manifest.diagnostics[0].source_pointer == pointer +@pytest.mark.parametrize( + ("field", "value", "pointer"), + [ + ("registryType", {}, "/packages/0/registryType"), + ("identifier", {}, "/packages/0/identifier"), + ("version", {}, "/packages/0/version"), + ("runtimeHint", {}, "/packages/0/runtimeHint"), + ("transport", {"type": {}}, "/packages/0/transport/type"), + ], +) +def test_malformed_server_descriptor_scalar_fields_are_partial( + tmp_path: Path, + field: str, + value: object, + pointer: str, +) -> None: + repo = _repo(tmp_path) + package: dict[str, object] = { + "registryType": "npm", + "identifier": "@fixture/mcp", + "version": "1.0.0", + "runtimeHint": "npx", + "transport": {"type": "stdio"}, + } + package[field] = value + (repo / "server.json").write_text( + json.dumps({"name": "fixture", "packages": [package]}), + encoding="utf-8", + ) + + manifest = build_release_trust_manifest(repo, None) + + assert manifest.discovery_coverage == "partial" + assert manifest.dependencies == [] + assert manifest.diagnostics[0].source_pointer == pointer + + +def test_trust_manifest_binds_the_full_dependency_occurrence(tmp_path: Path) -> None: + repo = _repo(tmp_path) + (repo / ".mcp.json").write_text( + '{"mcpServers":{"fixture":{"command":"npx","args":["@fixture/mcp@1.0.0"]}}}', + encoding="utf-8", + ) + manifest = build_release_trust_manifest(repo, None) + forged = manifest.model_dump(mode="json") + forged["entries"][0]["dependency"]["identity_name"] = "@fixture/other" + + with pytest.raises(ValueError, match="full dependency occurrence"): + ReleaseTrustManifest.model_validate(forged) + + +def test_trust_manifest_dependency_ids_are_unique(tmp_path: Path) -> None: + repo = _repo(tmp_path) + (repo / ".mcp.json").write_text( + '{"mcpServers":{"fixture":{"command":"npx","args":["@fixture/mcp@1.0.0"]}}}', + encoding="utf-8", + ) + manifest = build_release_trust_manifest(repo, None) + duplicated = manifest.model_dump(mode="json") + duplicated["dependencies"].append(dict(duplicated["dependencies"][0])) + duplicated["entries"].append(dict(duplicated["entries"][0])) + + with pytest.raises(ValueError, match="IDs must be unique"): + ReleaseTrustManifest.model_validate(duplicated) + + def test_ignored_staged_subject_input_marks_the_commit_unbound( tmp_path: Path, ) -> None: From 3c9de8272213248b741f8cfcf63b780a52532699 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 08:03:49 -0700 Subject: [PATCH 30/41] Recompute capsule semantic bindings --- CHANGELOG.md | 6 +- docs/OUTPUT-CONTRACT.md | 6 ++ docs/PROOF-BEFORE-ACTION.md | 5 + src/mcp_audit/proof_capsule.py | 54 +++++++++-- src/mcp_audit/proof_models.py | 46 +++++++++ tests/test_proof_before_action.py | 151 +++++++++++++++++++++++++++++- 6 files changed, 259 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b3303a..8bf1919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 tracebacks or falsely complete coverage. Registry scalar fields no longer coerce malformed values, trust entries bind the complete unique dependency occurrence, `current` evidence requires a complete authoritative scan record, - and masked evidence withholds every scan detail. + and masked evidence withholds every scan detail. Capsule verification + recomputes the comparison, staged-subject trust binding, and offline HTML + projection instead of accepting a merely self-consistent rehash. Current and + stale entries must agree with clean committed source chronology, and current + evidence requires complete diagnostic-free dependency discovery. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/OUTPUT-CONTRACT.md b/docs/OUTPUT-CONTRACT.md index 306f9a5..a7cf7ed 100644 --- a/docs/OUTPUT-CONTRACT.md +++ b/docs/OUTPUT-CONTRACT.md @@ -124,6 +124,12 @@ and limitations. `capsule-index.json` binds hashes and byte lengths for the JSON evidence and offline HTML view, plus subject and producer commits. Internal hashes prove consistency only. The verifier reports authority as `anchored` only when the caller supplies a matching independently recorded root SHA-256. +Verification also recomputes the declaration/observation comparison, checks the +trust manifest against the staged subject snapshot, and regenerates the offline +HTML projection. A self-consistently rehashed capsule cannot override those +semantic bindings. `current` or `stale` trust entries must also agree with a +clean committed trust source and its recorded scan/snapshot/evaluation +chronology; `current` additionally requires complete diagnostic-free discovery. `proof-before-action inspect` exits `0` for a passing comparison, `1` for a blocked or unknown comparison, and `2` when validation or observation cannot diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index 8c7797f..ad40be1 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -75,6 +75,11 @@ Without `--expect-root-sha256`, a successful verification result reports artifact. A supplied root reports `anchored` only when it matches; a mismatch is invalid and remains `authority: unverified`. +Verification recomputes the comparison from the declaration and observation, +requires the trust manifest to match the staged subject snapshot, and +regenerates the offline HTML. Updating all internal hashes cannot make a forged +comparison, detached trust entry, or misleading report valid. + Wheel and source-distribution builds use the repository's uv-backed PEP 517 wrapper to embed the exact source revision and pre-build dirty state. Installed commands read only that packaged metadata; source checkouts use Git only when diff --git a/src/mcp_audit/proof_capsule.py b/src/mcp_audit/proof_capsule.py index af2e886..af33f50 100644 --- a/src/mcp_audit/proof_capsule.py +++ b/src/mcp_audit/proof_capsule.py @@ -226,14 +226,10 @@ def build_capsule( subject = observation.subject_snapshot if subject is None: raise ValueError("new capsules require staged subject snapshot evidence") - if ( - trust_manifest.repository_commit != subject.repository_commit - or trust_manifest.repository_dirty != subject.repository_dirty - or trust_manifest.repository_staged_tree_sha256 != subject.staged_tree_sha256 - or trust_manifest.dependencies != subject.dependencies - or trust_manifest.diagnostics != subject.diagnostics - ): + if not _trust_manifest_matches_subject(observation, trust_manifest): raise ValueError("trust manifest subject evidence does not match the staged observation snapshot") + if comparison != compare_bill(declaration, observation): + raise ValueError("comparison does not match the declaration and observation") commit, dirty, provenance_source = _producer_state() producer_limitations: list[str] = [] if commit is None: @@ -275,6 +271,21 @@ def build_capsule( ) +def _trust_manifest_matches_subject( + observation: Observation, + trust_manifest: ReleaseTrustManifest, +) -> bool: + subject = observation.subject_snapshot + return bool( + subject is not None + and trust_manifest.repository_commit == subject.repository_commit + and trust_manifest.repository_dirty == subject.repository_dirty + and trust_manifest.repository_staged_tree_sha256 == subject.staged_tree_sha256 + and trust_manifest.dependencies == subject.dependencies + and trust_manifest.diagnostics == subject.diagnostics + ) + + def export_capsule(capsule: EvidenceCapsule, output: Path) -> str: if output.is_symlink(): raise ValueError("output directory must not be a symlink") @@ -432,6 +443,35 @@ def verify_capsule( payload_digest = sha256_bytes(canonical_json_bytes(raw["payload"])) if payload_digest != capsule.integrity.payload_sha256: errors.append({"code": "payload_tampered", "message": "payload hash mismatch"}) + if capsule.payload.observation.subject_snapshot is not None and not _trust_manifest_matches_subject( + capsule.payload.observation, + capsule.payload.trust_manifest, + ): + errors.append( + { + "code": "subject_manifest_mismatch", + "message": "trust manifest does not match the staged observation snapshot", + } + ) + expected_comparison = compare_bill( + capsule.payload.declaration, + capsule.payload.observation, + ) + if capsule.payload.comparison != expected_comparison: + errors.append( + { + "code": "comparison_mismatch", + "message": "comparison does not match the declaration and observation", + } + ) + expected_report = render_offline_html(capsule).encode("utf-8") + if (root / "report.html").read_bytes() != expected_report: + errors.append( + { + "code": "report_projection_mismatch", + "message": "offline report does not match the canonical capsule projection", + } + ) if expect_schema and capsule.schema_version != expect_schema: errors.append( { diff --git a/src/mcp_audit/proof_models.py b/src/mcp_audit/proof_models.py index 22b9957..ebc0c75 100644 --- a/src/mcp_audit/proof_models.py +++ b/src/mcp_audit/proof_models.py @@ -301,6 +301,14 @@ class TrustSource(StrictModel): snapshot_generated_at: str evaluated_at: str + @model_validator(mode="after") + def timestamps_are_chronological(self) -> TrustSource: + generated = _aware_datetime(self.snapshot_generated_at, "trust snapshot generation") + evaluated = _aware_datetime(self.evaluated_at, "trust evaluation") + if generated > evaluated: + raise ValueError("trust snapshot generation must not follow evaluation") + return self + class ReleaseTrustManifest(StrictModel): schema_version: Literal["proof-before-action.trust-manifest.v1"] = TRUST_MANIFEST_SCHEMA @@ -332,6 +340,32 @@ def every_dependency_has_one_entry(self) -> ReleaseTrustManifest: for dependency_id, entry in entries_by_id.items() ): raise ValueError("every trust entry must bind the full dependency occurrence") + chronological_entries = [ + entry for entry in self.entries if entry.evidence.state in {"current", "stale"} + ] + if chronological_entries: + if ( + self.trust_source is None + or self.trust_source.repository_commit is None + or self.trust_source.dirty is not False + ): + raise ValueError("current or stale trust evidence requires a clean committed source") + generated = _aware_datetime( + self.trust_source.snapshot_generated_at, + "trust snapshot generation", + ) + evaluated = _aware_datetime(self.trust_source.evaluated_at, "trust evaluation") + for entry in chronological_entries: + scanned = _aware_datetime(entry.evidence.scanned_at, "trust scan") + if scanned > generated or scanned > evaluated: + raise ValueError("trust scan must not follow snapshot generation or evaluation") + stale = (evaluated - scanned).days > 90 + if (entry.evidence.state == "stale") != stale: + raise ValueError("trust evidence state does not match recorded freshness") + if any(entry.evidence.state == "current" for entry in self.entries) and ( + self.discovery_coverage != "complete" or self.diagnostics + ): + raise ValueError("current trust evidence requires complete diagnostic-free discovery") return self @@ -407,6 +441,18 @@ def sha256_bytes(value: bytes) -> str: return hashlib.sha256(value).hexdigest() +def _aware_datetime(value: str | None, label: str) -> datetime: + if not value: + raise ValueError(f"{label} timestamp is required") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError(f"{label} timestamp must be valid") from exc + if parsed.tzinfo is None: + raise ValueError(f"{label} timestamp must be timezone-aware") + return parsed + + def _reject_floats(value: Any) -> None: if isinstance(value, float): raise ValueError("canonical Proof Before Action JSON forbids floating-point numbers") diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index b8f060d..1034776 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -23,12 +23,14 @@ build_capsule, compare_bill, export_capsule, + render_offline_html, verify_capsule, ) from mcp_audit.proof_cli import main from mcp_audit.proof_models import ( CAPSULE_SCHEMA, ActionDeclaration, + EvidenceCapsule, Observation, ReleaseTrustManifest, SubjectSnapshotEvidence, @@ -894,7 +896,7 @@ def test_loopback_network_attempt_is_detected_without_external_contact(tmp_path: assert "network_destination_unknown" in {item.code for item in declared_comparison.findings} -def test_declaration_omission_is_deterministic() -> None: +def test_declaration_omission_is_deterministic(tmp_path: Path) -> None: from mcp_audit.proof_models import ( CommandEvidence, FileChange, @@ -999,6 +1001,138 @@ def test_declaration_omission_is_deterministic() -> None: assert contradictory_comparison.verdict == "unknown" assert "observation_state_contradictory" in {item.code for item in contradictory_comparison.findings} + repo = _repo(tmp_path) + trust_manifest = build_release_trust_manifest( + repo, + None, + subject_snapshot=observation.subject_snapshot, + ) + forged_comparison = first.model_copy(update={"findings": [], "verdict": "pass"}) + with pytest.raises(ValueError, match="comparison does not match"): + build_capsule( + _declaration(), + observation, + forged_comparison, + trust_manifest, + ) + with pytest.raises(ValueError, match="trust manifest subject evidence"): + build_capsule( + _declaration(), + observation, + first, + trust_manifest.model_copy(update={"repository_dirty": True}), + ) + + +def test_verifier_recomputes_semantic_capsule_bindings(tmp_path: Path) -> None: + from mcp_audit.proof_models import ( + CommandEvidence, + IsolationEvidence, + NetworkEvidence, + SurfaceObservation, + ) + + unchanged = SurfaceObservation( + attempted=None, + decision="unknown", + outcome="unknown", + persisted="unchanged", + mechanism="fixture", + complete=False, + ) + observation = Observation( + subject_snapshot=SubjectSnapshotEvidence( + repository_commit=None, + repository_dirty=None, + staged_tree_sha256="d" * 64, + ), + isolation=IsolationEvidence( + image_reference="fixture", + image_id="sha256:" + "a" * 64, + runtime_user="65534:65534", + container_network_mode="none", + log_driver="none", + root_filesystem_read_only=True, + capabilities_dropped=True, + no_new_privileges=True, + pids_limit=128, + memory_bytes=536870912, + nano_cpus=1000000000, + tmpfs_paths=["/pba", "/tmp", "/workspace"], + containment="partial", + ), + command=CommandEvidence( + argv=["node"], + argv_sha256="c" * 64, + executable="node", + exit_code=0, + timed_out=False, + stdout_sha256="a" * 64, + stderr_sha256="a" * 64, + stdout_bytes=0, + stderr_bytes=0, + ), + filesystem=unchanged, + database=unchanged, + network=NetworkEvidence(surface=unchanged), + ) + declaration = _declaration() + comparison = compare_bill(declaration, observation) + repo = _repo(tmp_path) + trust_manifest = build_release_trust_manifest( + repo, + None, + subject_snapshot=observation.subject_snapshot, + ) + capsule = build_capsule(declaration, observation, comparison, trust_manifest) + output = tmp_path / "capsule" + export_capsule(capsule, output) + original_index = json.loads((output / "capsule-index.json").read_bytes()) + + def write_consistent_forgery(raw: dict[str, object]) -> None: + payload = raw["payload"] + assert isinstance(payload, dict) + integrity = raw["integrity"] + assert isinstance(integrity, dict) + integrity["payload_sha256"] = sha256_bytes(canonical_json_bytes(payload)) + forged = EvidenceCapsule.model_validate(raw) + capsule_bytes = canonical_json_bytes(forged) + report_bytes = render_offline_html(forged).encode("utf-8") + (output / "capsule.json").write_bytes(capsule_bytes) + (output / "report.html").write_bytes(report_bytes) + index = json.loads(json.dumps(original_index)) + for artifact in index["artifacts"]: + path = output / artifact["path"] + value = path.read_bytes() + artifact["bytes"] = len(value) + artifact["sha256"] = sha256_bytes(value) + (output / "capsule-index.json").write_bytes(canonical_json_bytes(index)) + + raw = capsule.model_dump(mode="json") + raw["payload"]["comparison"]["findings"] = [] + raw["payload"]["comparison"]["verdict"] = "pass" + write_consistent_forgery(raw) + result = verify_capsule(output) + assert "comparison_mismatch" in {item["code"] for item in result["errors"]} + + raw = capsule.model_dump(mode="json") + raw["payload"]["trust_manifest"]["repository_dirty"] = True + write_consistent_forgery(raw) + result = verify_capsule(output) + assert "subject_manifest_mismatch" in {item["code"] for item in result["errors"]} + + raw = capsule.model_dump(mode="json") + write_consistent_forgery(raw) + forged_report = b"forged pass" + (output / "report.html").write_bytes(forged_report) + index = json.loads((output / "capsule-index.json").read_bytes()) + report_artifact = next(item for item in index["artifacts"] if item["path"] == "report.html") + report_artifact["bytes"] = len(forged_report) + report_artifact["sha256"] = sha256_bytes(forged_report) + (output / "capsule-index.json").write_bytes(canonical_json_bytes(index)) + result = verify_capsule(output) + assert "report_projection_mismatch" in {item["code"] for item in result["errors"]} + def test_build_requirement_hooks_delegate_to_uv_build( monkeypatch: pytest.MonkeyPatch, @@ -1169,6 +1303,21 @@ def test_same_day_scan_uses_deterministic_end_of_day_freshness(tmp_path: Path) - assert evidence.state == "current" assert evidence.network_isolation == "verified_none" + forged = manifest.model_dump(mode="json") + forged["trust_source"]["dirty"] = True + with pytest.raises(ValueError, match="clean committed source"): + ReleaseTrustManifest.model_validate(forged) + + forged = manifest.model_dump(mode="json") + forged["entries"][0]["evidence"]["scanned_at"] = "2025-01-01T00:00:00+00:00" + with pytest.raises(ValueError, match="recorded freshness"): + ReleaseTrustManifest.model_validate(forged) + + forged = manifest.model_dump(mode="json") + forged["discovery_coverage"] = "partial" + with pytest.raises(ValueError, match="diagnostic-free discovery"): + ReleaseTrustManifest.model_validate(forged) + @pytest.mark.parametrize( "generated_at", From 78e28758968da2a1408471f8edb0927325549637 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 08:17:23 -0700 Subject: [PATCH 31/41] Bind commands and canonical capsule bytes --- CHANGELOG.md | 4 +++- docs/OUTPUT-CONTRACT.md | 3 +++ docs/PROOF-BEFORE-ACTION.md | 3 +++ src/mcp_audit/proof_capsule.py | 17 ++++++++++++++++- src/mcp_audit/proof_models.py | 23 ++++++++++++++++------- tests/test_proof_before_action.py | 29 +++++++++++++++++++++++++++-- 6 files changed, 68 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bf1919..55cd530 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,7 +56,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 recomputes the comparison, staged-subject trust binding, and offline HTML projection instead of accepting a merely self-consistent rehash. Current and stale entries must agree with clean committed source chronology, and current - evidence requires complete diagnostic-free dependency discovery. + evidence requires complete diagnostic-free dependency discovery. Command + identity is bound to canonical recorded argv, and verification rejects + noncanonical capsule or index encodings. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/OUTPUT-CONTRACT.md b/docs/OUTPUT-CONTRACT.md index a7cf7ed..673a8c5 100644 --- a/docs/OUTPUT-CONTRACT.md +++ b/docs/OUTPUT-CONTRACT.md @@ -130,6 +130,9 @@ HTML projection. A self-consistently rehashed capsule cannot override those semantic bindings. `current` or `stale` trust entries must also agree with a clean committed trust source and its recorded scan/snapshot/evaluation chronology; `current` additionally requires complete diagnostic-free discovery. +The recorded executable must match `argv[0]`, the argv digest must match the +canonical redacted argv, and both JSON files must already be byte-for-byte +canonical rather than merely parse to an equivalent object. `proof-before-action inspect` exits `0` for a passing comparison, `1` for a blocked or unknown comparison, and `2` when validation or observation cannot diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index ad40be1..fd1d88a 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -79,6 +79,9 @@ Verification recomputes the comparison from the declaration and observation, requires the trust manifest to match the staged subject snapshot, and regenerates the offline HTML. Updating all internal hashes cannot make a forged comparison, detached trust entry, or misleading report valid. +It also binds the recorded executable to the canonical redacted argv and rejects +JSON that is semantically equivalent but not in the documented canonical byte +encoding. Wheel and source-distribution builds use the repository's uv-backed PEP 517 wrapper to embed the exact source revision and pre-build dirty state. Installed diff --git a/src/mcp_audit/proof_capsule.py b/src/mcp_audit/proof_capsule.py index af33f50..cc8f5fc 100644 --- a/src/mcp_audit/proof_capsule.py +++ b/src/mcp_audit/proof_capsule.py @@ -409,13 +409,21 @@ def verify_capsule( index_bytes = (root / "capsule-index.json").read_bytes() root_sha256 = sha256_bytes(index_bytes) try: - index = CapsuleIndex.model_validate_json(index_bytes) + raw_index = json.loads(index_bytes) + index = CapsuleIndex.model_validate(raw_index) except Exception as exc: # Pydantic reports a stable failure class below. return { "valid": False, "root_sha256": root_sha256, "errors": [{"code": "index_schema_invalid", "message": type(exc).__name__}], } + if canonical_json_bytes(raw_index) != index_bytes: + errors.append( + { + "code": "index_noncanonical", + "message": "capsule index is not canonical JSON", + } + ) if index.schema_version != CAPSULE_INDEX_SCHEMA: errors.append({"code": "index_schema_unsupported", "message": index.schema_version}) for artifact in index.artifacts: @@ -440,6 +448,13 @@ def verify_capsule( errors.append({"code": "capsule_schema_invalid", "message": type(exc).__name__}) capsule = None if capsule is not None: + if canonical_json_bytes(raw) != capsule_bytes: + errors.append( + { + "code": "capsule_noncanonical", + "message": "capsule is not canonical JSON", + } + ) payload_digest = sha256_bytes(canonical_json_bytes(raw["payload"])) if payload_digest != capsule.integrity.payload_sha256: errors.append({"code": "payload_tampered", "message": "payload hash mismatch"}) diff --git a/src/mcp_audit/proof_models.py b/src/mcp_audit/proof_models.py index ebc0c75..e1d9f23 100644 --- a/src/mcp_audit/proof_models.py +++ b/src/mcp_audit/proof_models.py @@ -6,6 +6,7 @@ import json from datetime import datetime from enum import StrEnum +from pathlib import Path from typing import Any, Final, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -134,15 +135,23 @@ class IsolationEvidence(StrictModel): class CommandEvidence(StrictModel): - argv: list[str] - argv_sha256: str - executable: str + argv: list[str] = Field(min_length=1) + argv_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + executable: str = Field(min_length=1) exit_code: int | None timed_out: bool - stdout_sha256: str - stderr_sha256: str - stdout_bytes: int - stderr_bytes: int + stdout_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + stderr_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + stdout_bytes: int = Field(ge=0) + stderr_bytes: int = Field(ge=0) + + @model_validator(mode="after") + def executable_and_argv_are_bound(self) -> CommandEvidence: + if self.executable != Path(self.argv[0]).name: + raise ValueError("command executable must match argv[0]") + if self.argv_sha256 != sha256_bytes(canonical_json_bytes(self.argv)): + raise ValueError("command argv hash does not match recorded argv") + return self class NetworkEvidence(StrictModel): diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index 1034776..30d2f63 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -936,7 +936,7 @@ def test_declaration_omission_is_deterministic(tmp_path: Path) -> None: ), command=CommandEvidence( argv=["node"], - argv_sha256="c" * 64, + argv_sha256=sha256_bytes(canonical_json_bytes(["node"])), executable="node", exit_code=0, timed_out=False, @@ -1063,7 +1063,7 @@ def test_verifier_recomputes_semantic_capsule_bindings(tmp_path: Path) -> None: ), command=CommandEvidence( argv=["node"], - argv_sha256="c" * 64, + argv_sha256=sha256_bytes(canonical_json_bytes(["node"])), executable="node", exit_code=0, timed_out=False, @@ -1076,6 +1076,12 @@ def test_verifier_recomputes_semantic_capsule_bindings(tmp_path: Path) -> None: database=unchanged, network=NetworkEvidence(surface=unchanged), ) + command_payload = observation.command.model_dump(mode="json") + with pytest.raises(ValueError, match="executable must match"): + CommandEvidence.model_validate({**command_payload, "executable": "python"}) + with pytest.raises(ValueError, match="argv hash does not match"): + CommandEvidence.model_validate({**command_payload, "argv_sha256": "0" * 64}) + declaration = _declaration() comparison = compare_bill(declaration, observation) repo = _repo(tmp_path) @@ -1133,6 +1139,25 @@ def write_consistent_forgery(raw: dict[str, object]) -> None: result = verify_capsule(output) assert "report_projection_mismatch" in {item["code"] for item in result["errors"]} + write_consistent_forgery(capsule.model_dump(mode="json")) + capsule_path = output / "capsule.json" + noncanonical_capsule = json.dumps(json.loads(capsule_path.read_bytes()), indent=2).encode() + capsule_path.write_bytes(noncanonical_capsule) + index = json.loads((output / "capsule-index.json").read_bytes()) + capsule_artifact = next(item for item in index["artifacts"] if item["path"] == "capsule.json") + capsule_artifact["bytes"] = len(noncanonical_capsule) + capsule_artifact["sha256"] = sha256_bytes(noncanonical_capsule) + (output / "capsule-index.json").write_bytes(canonical_json_bytes(index)) + result = verify_capsule(output) + assert "capsule_noncanonical" in {item["code"] for item in result["errors"]} + + write_consistent_forgery(capsule.model_dump(mode="json")) + index_path = output / "capsule-index.json" + noncanonical_index = json.dumps(json.loads(index_path.read_bytes()), indent=2).encode() + index_path.write_bytes(noncanonical_index) + result = verify_capsule(output) + assert "index_noncanonical" in {item["code"] for item in result["errors"]} + def test_build_requirement_hooks_delegate_to_uv_build( monkeypatch: pytest.MonkeyPatch, From b0ba8bc6b690a88d055acbf83f742cdc12906319 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 08:29:58 -0700 Subject: [PATCH 32/41] Reject coerced capsule scalar types --- CHANGELOG.md | 4 ++- docs/OUTPUT-CONTRACT.md | 3 +++ docs/PROOF-BEFORE-ACTION.md | 2 ++ src/mcp_audit/proof_capsule.py | 10 ++++--- tests/test_proof_before_action.py | 45 +++++++++++++++++++++++++++++++ 5 files changed, 59 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55cd530..2361626 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,7 +58,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 stale entries must agree with clean committed source chronology, and current evidence requires complete diagnostic-free dependency discovery. Command identity is bound to canonical recorded argv, and verification rejects - noncanonical capsule or index encodings. + noncanonical capsule or index encodings. Untrusted JSON is validated without + scalar coercion, and float/canonicalization failures return structured invalid + results instead of escaping the verifier. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/OUTPUT-CONTRACT.md b/docs/OUTPUT-CONTRACT.md index 673a8c5..4e5ce46 100644 --- a/docs/OUTPUT-CONTRACT.md +++ b/docs/OUTPUT-CONTRACT.md @@ -133,6 +133,9 @@ chronology; `current` additionally requires complete diagnostic-free discovery. The recorded executable must match `argv[0]`, the argv digest must match the canonical redacted argv, and both JSON files must already be byte-for-byte canonical rather than merely parse to an equivalent object. +Untrusted capsule and index bytes are validated in strict JSON mode: stringified +booleans/integers and floating-point substitutes are invalid, never coerced. +Schema or canonicalization failures remain structured verifier results. `proof-before-action inspect` exits `0` for a passing comparison, `1` for a blocked or unknown comparison, and `2` when validation or observation cannot diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index fd1d88a..45744c1 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -82,6 +82,8 @@ comparison, detached trust entry, or misleading report valid. It also binds the recorded executable to the canonical redacted argv and rejects JSON that is semantically equivalent but not in the documented canonical byte encoding. +Verification uses strict JSON scalar types, so stringified booleans or integers +and floating-point substitutes are invalid rather than coerced. Wheel and source-distribution builds use the repository's uv-backed PEP 517 wrapper to embed the exact source revision and pre-build dirty state. Installed diff --git a/src/mcp_audit/proof_capsule.py b/src/mcp_audit/proof_capsule.py index cc8f5fc..92c5acb 100644 --- a/src/mcp_audit/proof_capsule.py +++ b/src/mcp_audit/proof_capsule.py @@ -410,14 +410,15 @@ def verify_capsule( root_sha256 = sha256_bytes(index_bytes) try: raw_index = json.loads(index_bytes) - index = CapsuleIndex.model_validate(raw_index) + index = CapsuleIndex.model_validate_json(index_bytes, strict=True) + canonical_index_bytes = canonical_json_bytes(raw_index) except Exception as exc: # Pydantic reports a stable failure class below. return { "valid": False, "root_sha256": root_sha256, "errors": [{"code": "index_schema_invalid", "message": type(exc).__name__}], } - if canonical_json_bytes(raw_index) != index_bytes: + if canonical_index_bytes != index_bytes: errors.append( { "code": "index_noncanonical", @@ -443,12 +444,13 @@ def verify_capsule( actual_schema = raw.get("schema_version") if actual_schema != CAPSULE_SCHEMA: errors.append({"code": "capsule_schema_unsupported", "message": str(actual_schema)}) - capsule = EvidenceCapsule.model_validate(raw) + capsule = EvidenceCapsule.model_validate_json(capsule_bytes, strict=True) + canonical_capsule_bytes = canonical_json_bytes(raw) except Exception as exc: errors.append({"code": "capsule_schema_invalid", "message": type(exc).__name__}) capsule = None if capsule is not None: - if canonical_json_bytes(raw) != capsule_bytes: + if canonical_capsule_bytes != capsule_bytes: errors.append( { "code": "capsule_noncanonical", diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index 30d2f63..e3abfa9 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -1158,6 +1158,51 @@ def write_consistent_forgery(raw: dict[str, object]) -> None: result = verify_capsule(output) assert "index_noncanonical" in {item["code"] for item in result["errors"]} + def bind_capsule_bytes(capsule_bytes: bytes) -> None: + (output / "capsule.json").write_bytes(capsule_bytes) + index = json.loads(json.dumps(original_index)) + for artifact in index["artifacts"]: + value = (output / artifact["path"]).read_bytes() + artifact["bytes"] = len(value) + artifact["sha256"] = sha256_bytes(value) + (output / "capsule-index.json").write_bytes(canonical_json_bytes(index)) + + for field_path, malformed_value in [ + (("observation", "filesystem", "complete"), "false"), + (("observation", "command", "exit_code"), "0"), + (("observation", "command", "stdout_bytes"), "0"), + ]: + write_consistent_forgery(capsule.model_dump(mode="json")) + raw = capsule.model_dump(mode="json") + target = raw["payload"] + for key in field_path[:-1]: + target = target[key] + target[field_path[-1]] = malformed_value + raw["integrity"]["payload_sha256"] = sha256_bytes(canonical_json_bytes(raw["payload"])) + bind_capsule_bytes(canonical_json_bytes(raw)) + result = verify_capsule(output) + assert "capsule_schema_invalid" in {item["code"] for item in result["errors"]} + + write_consistent_forgery(capsule.model_dump(mode="json")) + raw = capsule.model_dump(mode="json") + raw["payload"]["observation"]["command"]["stdout_bytes"] = 0.0 + float_capsule = ( + json.dumps(raw, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + b"\n" + ) + bind_capsule_bytes(float_capsule) + result = verify_capsule(output) + assert "capsule_schema_invalid" in {item["code"] for item in result["errors"]} + + write_consistent_forgery(capsule.model_dump(mode="json")) + index = json.loads((output / "capsule-index.json").read_bytes()) + index["artifacts"][0]["bytes"] = float(index["artifacts"][0]["bytes"]) + float_index = ( + json.dumps(index, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + b"\n" + ) + (output / "capsule-index.json").write_bytes(float_index) + result = verify_capsule(output) + assert "index_schema_invalid" in {item["code"] for item in result["errors"]} + def test_build_requirement_hooks_delegate_to_uv_build( monkeypatch: pytest.MonkeyPatch, From 74dbc3bad41977ba485575a36cab0c62e3081200 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 08:38:55 -0700 Subject: [PATCH 33/41] Reject malformed repository trust inputs --- CHANGELOG.md | 4 +- docs/PROOF-BEFORE-ACTION.md | 3 ++ src/mcp_audit/proof_trust.py | 65 +++++++++++++++++++++++++++++-- tests/test_proof_before_action.py | 38 ++++++++++++++++++ 4 files changed, 105 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2361626..c26e6ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,7 +60,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 identity is bound to canonical recorded argv, and verification rejects noncanonical capsule or index encodings. Untrusted JSON is validated without scalar coercion, and float/canonicalization failures return structured invalid - results instead of escaping the verifier. + results instead of escaping the verifier. Malformed repository command, URL, + argument, environment, header, or transport fields now make discovery partial + instead of being normalized into a trust match. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index 45744c1..aa58e67 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -166,6 +166,9 @@ observed MCP surface, not an endorsement or runtime-safety proof. Seed identity/slug fields and every grade-bearing snapshot field must also match their documented scalar/object shapes; malformed nested data makes the complete local trust source UNKNOWN instead of being coerced into evidence. +Repository config command, URL, argument, environment, header, and transport +shapes are likewise type-checked before identity matching; malformed values +produce exact-pointer partial-discovery diagnostics and no trust match. Snapshot generation time must be a valid, non-future, timezone-aware timestamp that is not earlier than any contained scan. A record without proven network isolation cannot be `current`, even when its dependency match is otherwise exact. diff --git a/src/mcp_audit/proof_trust.py b/src/mcp_audit/proof_trust.py index cae500f..e81ed06 100644 --- a/src/mcp_audit/proof_trust.py +++ b/src/mcp_audit/proof_trust.py @@ -128,6 +128,10 @@ def discover_repo_mcp( ) ) continue + entry_diagnostics = _config_entry_diagnostics(relative, pointer, config) + if entry_diagnostics: + diagnostics.extend(entry_diagnostics) + continue dependencies.append(_config_occurrence(relative, pointer, str(name), config)) package_json = repo / "package.json" @@ -435,7 +439,7 @@ def _config_occurrence( source_path: str, pointer: str, name: str, config: dict[str, Any] ) -> DependencyOccurrence: command = config.get("command") - args = [str(item) for item in config.get("args", [])] if isinstance(config.get("args", []), list) else [] + args = cast(list[str], config.get("args", [])) url = config.get("url") kind = "unknown" identity: str | None = None @@ -457,9 +461,8 @@ def _config_occurrence( kind = "binary" identity = basename exact = True - env_keys = sorted(str(key) for key in config.get("env", {}) if isinstance(key, str)) - headers = config.get("headers", {}) - header_keys = sorted(str(key) for key in headers if isinstance(headers, dict)) + env_keys = sorted(cast(dict[str, str], config.get("env", {}))) + header_keys = sorted(cast(dict[str, str], config.get("headers", {}))) return _occurrence( source_path=source_path, source_pointer=pointer, @@ -476,6 +479,60 @@ def _config_occurrence( ) +def _config_entry_diagnostics( + source_path: str, + pointer: str, + config: dict[str, Any], +) -> list[DiscoveryDiagnostic]: + diagnostics: list[DiscoveryDiagnostic] = [] + + def invalid(suffix: str, message: str) -> None: + diagnostics.append( + DiscoveryDiagnostic( + source_path=source_path, + source_pointer=f"{pointer}{suffix}", + code="invalid_entry", + message=message, + ) + ) + + command = config.get("command") + url = config.get("url") + if "command" in config and (not isinstance(command, str) or not command): + invalid("/command", "command must be a non-empty string") + if "url" in config and (not isinstance(url, str) or not url): + invalid("/url", "url must be a non-empty string") + if isinstance(command, str) and command and isinstance(url, str) and url: + invalid("", "server entry must not define both command and url") + if not (isinstance(command, str) and command) and not (isinstance(url, str) and url): + invalid("", "server entry must define a valid command or url") + + args = config.get("args", []) + if not isinstance(args, list): + invalid("/args", "args must be an array of strings") + else: + for index, value in enumerate(args): + if not isinstance(value, str): + invalid(f"/args/{index}", "argument must be a string") + + for field in ("env", "headers"): + values = config.get(field, {}) + if not isinstance(values, dict): + invalid(f"/{field}", f"{field} must be an object of string values") + continue + for key, value in values.items(): + if not isinstance(value, str): + invalid( + f"/{field}/{_json_pointer(str(key))}", + f"{field} value must be a string", + ) + + transport = config.get("type") + if "type" in config and (not isinstance(transport, str) or not transport): + invalid("/type", "transport type must be a non-empty string") + return diagnostics + + def _package_occurrence( source_path: str, pointer: str, name: str, spec: str, kind: str ) -> DependencyOccurrence: diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index e3abfa9..65fa3fd 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -590,6 +590,44 @@ def test_discovery_preserves_the_selected_server_map_pointer( assert manifest.diagnostics[0].source_pointer == "/servers/broken" +@pytest.mark.parametrize( + ("updates", "pointer"), + [ + ({"args": [0]}, "/mcpServers/fixture/args/0"), + ({"args": "not-an-array"}, "/mcpServers/fixture/args"), + ({"command": 0}, "/mcpServers/fixture/command"), + ({"url": 0}, "/mcpServers/fixture/url"), + ({"url": "https://example.invalid/mcp"}, "/mcpServers/fixture"), + ({"env": []}, "/mcpServers/fixture/env"), + ({"env": {"TOKEN": 0}}, "/mcpServers/fixture/env/TOKEN"), + ({"headers": []}, "/mcpServers/fixture/headers"), + ({"headers": {"X-Test": 0}}, "/mcpServers/fixture/headers/X-Test"), + ({"type": 0}, "/mcpServers/fixture/type"), + ], +) +def test_malformed_server_config_fields_are_partial_and_unmatched( + tmp_path: Path, + updates: dict[str, object], + pointer: str, +) -> None: + repo = _repo(tmp_path) + config: dict[str, object] = { + "command": "npx", + "args": ["@fixture/known-mcp"], + } + config.update(updates) + (repo / ".mcp.json").write_text( + json.dumps({"mcpServers": {"fixture": config}}), + encoding="utf-8", + ) + + manifest = build_release_trust_manifest(repo, None) + + assert manifest.discovery_coverage == "partial" + assert manifest.dependencies == [] + assert pointer in {item.source_pointer for item in manifest.diagnostics} + + @pytest.mark.parametrize( "section", ["dependencies", "devDependencies", "optionalDependencies"], From 93aefcecb4e0349ebcaef9cf046bdc03928f821a Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 08:48:25 -0700 Subject: [PATCH 34/41] Prevent trust identity aliasing --- CHANGELOG.md | 4 +++- docs/PROOF-BEFORE-ACTION.md | 4 ++++ src/mcp_audit/proof_trust.py | 40 ++++++++++++++++++++++++++++++- tests/test_proof_before_action.py | 38 +++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c26e6ae..00208a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,7 +62,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 scalar coercion, and float/canonicalization failures return structured invalid results instead of escaping the verifier. Malformed repository command, URL, argument, environment, header, or transport fields now make discovery partial - instead of being normalized into a trust match. + instead of being normalized into a trust match. Query-bearing remote URLs no + longer alias their base endpoint, and npm package-selection/call options + cannot be skipped to apply trust to a different positional argument. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index aa58e67..8658fa0 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -169,6 +169,10 @@ local trust source UNKNOWN instead of being coerced into evidence. Repository config command, URL, argument, environment, header, and transport shapes are likewise type-checked before identity matching; malformed values produce exact-pointer partial-discovery diagnostics and no trust match. +Query-, fragment-, or userinfo-bearing remote endpoints receive a private +full-URL hash so they cannot alias a base endpoint or leak sensitive URL +components. npm package-selection/call options are partial and non-authoritative +until one executed dependency can be modeled exactly. Snapshot generation time must be a valid, non-future, timezone-aware timestamp that is not earlier than any contained scan. A record without proven network isolation cannot be `current`, even when its dependency match is otherwise exact. diff --git a/src/mcp_audit/proof_trust.py b/src/mcp_audit/proof_trust.py index e81ed06..cbfc543 100644 --- a/src/mcp_audit/proof_trust.py +++ b/src/mcp_audit/proof_trust.py @@ -530,6 +530,17 @@ def invalid(suffix: str, message: str) -> None: transport = config.get("type") if "type" in config and (not isinstance(transport, str) or not transport): invalid("/type", "transport type must be a non-empty string") + if ( + isinstance(command, str) + and Path(command).name.lower() in {"npx", "npm", "pnpm", "yarn"} + and isinstance(args, list) + and all(isinstance(value, str) for value in args) + and _npm_args_have_unbound_package_selection(args) + ): + invalid( + "/args", + "npm package-selection or call options cannot be bound to one dependency", + ) return diagnostics @@ -984,7 +995,11 @@ def _dependency_key(dependency: DependencyOccurrence) -> tuple[str, str]: def _parse_npm_args(args: list[str]) -> tuple[str, str | None, str | None, bool]: - candidates = [item for item in args if item and not item.startswith("-")] + if _npm_args_have_unbound_package_selection(args): + return "npm", None, None, False + candidates = [ + item for item in args if item and not item.startswith("-") and item not in {"exec", "dlx", "x"} + ] if not candidates: return "npm", None, None, False raw = candidates[0] @@ -998,6 +1013,20 @@ def _parse_npm_args(args: list[str]) -> tuple[str, str | None, str | None, bool] return "npm", _normalize_package(name, "npm"), version if exact else None, exact +def _npm_args_have_unbound_package_selection(args: list[str]) -> bool: + selectors = ("--package", "-p", "--call", "-c") + for item in args: + if item == "--": + break + if item in {"exec", "dlx", "x"}: + continue + if item in selectors or any(item.startswith(f"{selector}=") for selector in selectors): + return True + if item and not item.startswith("-"): + break + return False + + def _parse_pypi_args(args: list[str]) -> tuple[str, str | None, str | None, bool]: candidates = [item for item in args if item and not item.startswith("-") and item not in {"run", "tool"}] if not candidates: @@ -1028,6 +1057,15 @@ def _normalize_remote(url: str) -> str: parsed = urlsplit(url) except ValueError: return "sha256:" + hashlib.sha256(url.encode()).hexdigest() + if ( + parsed.scheme.lower() not in {"http", "https"} + or not parsed.hostname + or parsed.query + or parsed.fragment + or parsed.username + or parsed.password + ): + return "sha256:" + hashlib.sha256(url.encode()).hexdigest() host = parsed.hostname or "" try: private = ipaddress.ip_address(host).is_private diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index 65fa3fd..e76e758 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -603,6 +603,16 @@ def test_discovery_preserves_the_selected_server_map_pointer( ({"headers": []}, "/mcpServers/fixture/headers"), ({"headers": {"X-Test": 0}}, "/mcpServers/fixture/headers/X-Test"), ({"type": 0}, "/mcpServers/fixture/type"), + ( + {"args": ["--package=@attacker/mcp", "--", "safe"]}, + "/mcpServers/fixture/args", + ), + ( + {"args": ["--package", "@attacker/mcp", "--", "safe"]}, + "/mcpServers/fixture/args", + ), + ({"args": ["-p", "@attacker/mcp", "safe"]}, "/mcpServers/fixture/args"), + ({"args": ["-c", "safe"]}, "/mcpServers/fixture/args"), ], ) def test_malformed_server_config_fields_are_partial_and_unmatched( @@ -628,6 +638,34 @@ def test_malformed_server_config_fields_are_partial_and_unmatched( assert pointer in {item.source_pointer for item in manifest.diagnostics} +def test_query_bearing_remote_identity_cannot_alias_the_base_endpoint( + tmp_path: Path, +) -> None: + repo = _repo(tmp_path) + (repo / ".mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "base": {"url": "https://gateway.example/mcp"}, + "tenant": { + "url": "https://gateway.example/mcp?tenant=unreviewed", + }, + } + } + ), + encoding="utf-8", + ) + + manifest = build_release_trust_manifest(repo, None) + identities = {item.config_name: item.identity_name for item in manifest.dependencies} + + assert identities["base"] == "https://gateway.example/mcp" + assert identities["tenant"] is not None + assert identities["tenant"].startswith("sha256:") + assert identities["tenant"] != identities["base"] + assert "tenant=unreviewed" not in identities["tenant"] + + @pytest.mark.parametrize( "section", ["dependencies", "devDependencies", "optionalDependencies"], From 62aece81bff5f8d288da2be54975d8bd987a636b Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 08:57:07 -0700 Subject: [PATCH 35/41] Preserve exact remote trust paths --- CHANGELOG.md | 4 +++- docs/PROOF-BEFORE-ACTION.md | 4 +++- src/mcp_audit/proof_trust.py | 10 ++++++--- tests/test_proof_before_action.py | 36 +++++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00208a2..24d0fcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,7 +64,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 argument, environment, header, or transport fields now make discovery partial instead of being normalized into a trust match. Query-bearing remote URLs no longer alias their base endpoint, and npm package-selection/call options - cannot be skipped to apply trust to a different positional argument. + cannot be skipped to apply trust to a different positional argument. Remote + paths preserve trailing slashes, and Boolean trust schema versions fail shape + validation instead of passing as Python integers. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index 8658fa0..a8fd08c 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -172,7 +172,9 @@ produce exact-pointer partial-discovery diagnostics and no trust match. Query-, fragment-, or userinfo-bearing remote endpoints receive a private full-URL hash so they cannot alias a base endpoint or leak sensitive URL components. npm package-selection/call options are partial and non-authoritative -until one executed dependency can be modeled exactly. +until one executed dependency can be modeled exactly. Public remote paths retain +their exact trailing-slash semantics, and Boolean values never satisfy integer +trust schema-version fields. Snapshot generation time must be a valid, non-future, timezone-aware timestamp that is not earlier than any contained scan. A record without proven network isolation cannot be `current`, even when its dependency match is otherwise exact. diff --git a/src/mcp_audit/proof_trust.py b/src/mcp_audit/proof_trust.py index cbfc543..235ba94 100644 --- a/src/mcp_audit/proof_trust.py +++ b/src/mcp_audit/proof_trust.py @@ -760,9 +760,9 @@ def _valid_trust_input_shapes( if not isinstance(snapshot, dict) or not isinstance(spec_shift, dict): return False if ( - not isinstance(snapshot.get("schema_version"), (int, str)) + not _valid_schema_version(snapshot.get("schema_version")) or not isinstance(snapshot.get("generated_at"), str) - or not isinstance(spec_shift.get("format_version"), (int, str)) + or not _valid_schema_version(spec_shift.get("format_version")) or not isinstance(spec_shift.get("servers"), dict) ): return False @@ -781,6 +781,10 @@ def _valid_trust_input_shapes( return isinstance(masked, list) and all(isinstance(item, str) for item in masked) +def _valid_schema_version(value: Any) -> bool: + return isinstance(value, str) or (isinstance(value, int) and not isinstance(value, bool)) + + def _trust_timestamp(value: Any) -> datetime | None: if not isinstance(value, str) or not value: return None @@ -1076,7 +1080,7 @@ def _normalize_remote(url: str) -> str: netloc = host.lower() if parsed.port: netloc += f":{parsed.port}" - return urlunsplit((parsed.scheme.lower(), netloc, parsed.path.rstrip("/"), "", "")) + return urlunsplit((parsed.scheme.lower(), netloc, parsed.path, "", "")) def _is_stale(scanned_at: Any, evaluated_at: str) -> bool | None: diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index e76e758..519d547 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -650,6 +650,7 @@ def test_query_bearing_remote_identity_cannot_alias_the_base_endpoint( "tenant": { "url": "https://gateway.example/mcp?tenant=unreviewed", }, + "trailing": {"url": "https://gateway.example/mcp/"}, } } ), @@ -664,6 +665,8 @@ def test_query_bearing_remote_identity_cannot_alias_the_base_endpoint( assert identities["tenant"].startswith("sha256:") assert identities["tenant"] != identities["base"] assert "tenant=unreviewed" not in identities["tenant"] + assert identities["trailing"] == "https://gateway.example/mcp/" + assert identities["trailing"] != identities["base"] @pytest.mark.parametrize( @@ -1765,6 +1768,39 @@ def test_wrong_shaped_trust_inputs_become_structured_unknown( assert "mcp-trust source has an unsupported data shape" in manifest.limitations +@pytest.mark.parametrize( + ("relative", "field"), + [ + ("src/mcp_trust/catalog_snapshot.json", "schema_version"), + ("src/mcp_trust/core/spec_shift_verdicts.json", "format_version"), + ], +) +def test_boolean_trust_schema_versions_are_structured_unknown( + tmp_path: Path, + relative: str, + field: str, +) -> None: + repo = _repo(tmp_path) + (repo / ".mcp.json").write_text( + '{"mcpServers":{"known":{"command":"npx","args":["@fixture/known-mcp"]}}}', + encoding="utf-8", + ) + trust = _trust_fixture(tmp_path) + path = trust / relative + payload = json.loads(path.read_text(encoding="utf-8")) + payload[field] = True + path.write_text(json.dumps(payload), encoding="utf-8") + _commit_trust_fixture(trust, "commit Boolean trust schema version") + + manifest = build_release_trust_manifest(repo, trust) + + assert manifest.discovery_coverage == "unknown" + assert manifest.trust_source is None + assert manifest.entries[0].evidence.state == "unverifiable" + assert manifest.entries[0].evidence.grade is None + assert "mcp-trust source has an unsupported data shape" in manifest.limitations + + @pytest.mark.parametrize( ("relative", "field_path"), [ From 38fb470d095e28ac72609ee7a378909921e89ea5 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 09:13:58 -0700 Subject: [PATCH 36/41] Fail closed on transient and unbound evidence --- CHANGELOG.md | 10 +++-- docs/OUTPUT-CONTRACT.md | 3 ++ docs/PROOF-BEFORE-ACTION.md | 11 ++++-- src/mcp_audit/proof_capsule.py | 64 ++++++++++++++++++++++++++----- src/mcp_audit/proof_trust.py | 24 +++++++++++- tests/test_proof_before_action.py | 48 ++++++++++++++++++++++- 6 files changed, 141 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24d0fcb..3cea639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,8 +32,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 pre-execution staged-tree hash, and trust parsing/hashing/commit comparison uses one captured byte snapshot to prevent clean-after-read races. No-follow, directory-relative source descriptors close validation-to-copy link races, - while legacy observation-v1 capsules remain verification-compatible and new - capsules require the staged subject binding. Directory traversal/reopen + while legacy observation-v1 capsules remain parseable but verify invalid + without the staged subject binding required of all authoritative capsules. + Directory traversal/reopen failures and accepted-but-untraversed directories now block instead of silently omitting a changing subtree. Nested mcp-trust seed identities and grade-bearing snapshot fields are type-validated before any row can become @@ -66,7 +67,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 longer alias their base endpoint, and npm package-selection/call options cannot be skipped to apply trust to a different positional argument. Remote paths preserve trailing slashes, and Boolean trust schema versions fail shape - validation instead of passing as Python integers. + validation instead of passing as Python integers. Raw remote URL controls and + invalid ports become partial discovery diagnostics. Complete observers must + treat transient filesystem/database attempts as effects, and verification + rejects any capsule missing either staged-subject binding. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/OUTPUT-CONTRACT.md b/docs/OUTPUT-CONTRACT.md index 4e5ce46..67a7479 100644 --- a/docs/OUTPUT-CONTRACT.md +++ b/docs/OUTPUT-CONTRACT.md @@ -136,6 +136,9 @@ canonical rather than merely parse to an equivalent object. Untrusted capsule and index bytes are validated in strict JSON mode: stringified booleans/integers and floating-point substitutes are invalid, never coerced. Schema or canonicalization failures remain structured verifier results. +Missing staged-subject evidence is always invalid, including parseable legacy-v1 +payloads. A complete observer's transient filesystem or database attempt counts +as an observed effect even when it leaves no persisted delta. `proof-before-action inspect` exits `0` for a passing comparison, `1` for a blocked or unknown comparison, and `2` when validation or observation cannot diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index a8fd08c..751fb85 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -174,7 +174,9 @@ full-URL hash so they cannot alias a base endpoint or leak sensitive URL components. npm package-selection/call options are partial and non-authoritative until one executed dependency can be modeled exactly. Public remote paths retain their exact trailing-slash semantics, and Boolean values never satisfy integer -trust schema-version fields. +trust schema-version fields. Raw URL whitespace/control characters and invalid +ports produce partial-discovery diagnostics instead of parser normalization or +exceptions. Snapshot generation time must be a valid, non-future, timezone-aware timestamp that is not earlier than any contained scan. A record without proven network isolation cannot be `current`, even when its dependency match is otherwise exact. @@ -207,9 +209,10 @@ The capsule index is versioned separately so the portable envelope can evolve without silently changing capsule semantics. Legacy observation-v1 capsules emitted before staged subject evidence was added -remain verifiable. New capsule construction requires `subject_snapshot` and the -matching manifest staged-tree hash; compatibility parsing does not let new -producers omit that binding. +remain parseable, but verification marks them invalid and unbound. Every valid +capsule requires `subject_snapshot` and the matching manifest staged-tree hash; +there is no compatibility path that turns missing subject evidence into +authority. Canonical JSON uses UTF-8, sorted keys, compact separators, one terminal newline, and no floating-point values. The primitive is compatible with AIGCCore's diff --git a/src/mcp_audit/proof_capsule.py b/src/mcp_audit/proof_capsule.py index 92c5acb..f6cd754 100644 --- a/src/mcp_audit/proof_capsule.py +++ b/src/mcp_audit/proof_capsule.py @@ -51,14 +51,28 @@ def compare_bill(declaration: ActionDeclaration, observation: Observation) -> Bi evidence=[executable], ) ) - if non_database_file_changes: + filesystem_attempt_without_persisted_change = ( + observation.filesystem.attempted is True and not observation.file_changes + ) + filesystem_effect_observed = ( + bool(non_database_file_changes) or filesystem_attempt_without_persisted_change + ) + if filesystem_effect_observed: capabilities.append("file_write") if declaration.side_effects.filesystem != "write" and "file_write" not in declaration.permissions: findings.append( ComparisonFinding( - code="undeclared_file_write", + code=( + "undeclared_file_write" + if non_database_file_changes + else "undeclared_file_write_attempt" + ), severity="error", - message="the command changed files without declaring file-write authority", + message=( + "the command changed files without declaring file-write authority" + if non_database_file_changes + else "the command attempted a file write without declaring file-write authority" + ), evidence=[item.path for item in non_database_file_changes], ) ) @@ -77,14 +91,26 @@ def compare_bill(declaration: ActionDeclaration, observation: Observation) -> Bi evidence=outside, ) ) - if observation.database_changes: + database_effect_observed = bool(observation.database_changes) or observation.database.attempted is True + if database_effect_observed: capabilities.append("database_write") if declaration.side_effects.database != "write" and "database_write" not in declaration.permissions: findings.append( ComparisonFinding( - code="undeclared_database_write", + code=( + "undeclared_database_write" + if observation.database_changes + else "undeclared_database_write_attempt" + ), severity="error", - message="the command changed a database without declaring database-write authority", + message=( + "the command changed a database without declaring database-write authority" + if observation.database_changes + else ( + "the command attempted a database write without declaring " + "database-write authority" + ) + ), evidence=[item.path for item in observation.database_changes], ) ) @@ -460,9 +486,29 @@ def verify_capsule( payload_digest = sha256_bytes(canonical_json_bytes(raw["payload"])) if payload_digest != capsule.integrity.payload_sha256: errors.append({"code": "payload_tampered", "message": "payload hash mismatch"}) - if capsule.payload.observation.subject_snapshot is not None and not _trust_manifest_matches_subject( - capsule.payload.observation, - capsule.payload.trust_manifest, + subject_snapshot_missing = capsule.payload.observation.subject_snapshot is None + manifest_binding_missing = capsule.payload.trust_manifest.repository_staged_tree_sha256 is None + if subject_snapshot_missing: + errors.append( + { + "code": "subject_snapshot_missing", + "message": "staged subject snapshot evidence is required", + } + ) + if manifest_binding_missing: + errors.append( + { + "code": "subject_manifest_binding_missing", + "message": "trust manifest staged-tree binding is required", + } + ) + if ( + not subject_snapshot_missing + and not manifest_binding_missing + and not _trust_manifest_matches_subject( + capsule.payload.observation, + capsule.payload.trust_manifest, + ) ): errors.append( { diff --git a/src/mcp_audit/proof_trust.py b/src/mcp_audit/proof_trust.py index 235ba94..38960e6 100644 --- a/src/mcp_audit/proof_trust.py +++ b/src/mcp_audit/proof_trust.py @@ -502,6 +502,10 @@ def invalid(suffix: str, message: str) -> None: invalid("/command", "command must be a non-empty string") if "url" in config and (not isinstance(url, str) or not url): invalid("/url", "url must be a non-empty string") + elif isinstance(url, str): + remote_error = _remote_url_error(url) + if remote_error is not None: + invalid("/url", remote_error) if isinstance(command, str) and command and isinstance(url, str) and url: invalid("", "server entry must not define both command and url") if not (isinstance(command, str) and command) and not (isinstance(url, str) and url): @@ -1057,8 +1061,11 @@ def _normalize_package(name: str, kind: str) -> str: def _normalize_remote(url: str) -> str: + if _remote_url_error(url) is not None: + return "sha256:" + hashlib.sha256(url.encode()).hexdigest() try: parsed = urlsplit(url) + port = parsed.port except ValueError: return "sha256:" + hashlib.sha256(url.encode()).hexdigest() if ( @@ -1078,11 +1085,24 @@ def _normalize_remote(url: str) -> str: if private: return "sha256:" + hashlib.sha256(url.encode()).hexdigest() netloc = host.lower() - if parsed.port: - netloc += f":{parsed.port}" + if port: + netloc += f":{port}" return urlunsplit((parsed.scheme.lower(), netloc, parsed.path, "", "")) +def _remote_url_error(url: str) -> str | None: + if any(ord(character) < 0x20 or ord(character) == 0x7F or character.isspace() for character in url): + return "url must not contain whitespace or control characters" + try: + parsed = urlsplit(url) + _ = parsed.port + except ValueError: + return "url must contain a valid port" + if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname: + return "url must be an absolute HTTP(S) endpoint" + return None + + def _is_stale(scanned_at: Any, evaluated_at: str) -> bool | None: if not isinstance(scanned_at, str) or not evaluated_at: return None diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index 519d547..74bb4ef 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -603,6 +603,11 @@ def test_discovery_preserves_the_selected_server_map_pointer( ({"headers": []}, "/mcpServers/fixture/headers"), ({"headers": {"X-Test": 0}}, "/mcpServers/fixture/headers/X-Test"), ({"type": 0}, "/mcpServers/fixture/type"), + ( + {"url": "https://gateway.example:not-a-port/mcp"}, + "/mcpServers/fixture/url", + ), + ({"url": "https://gateway.example/m\tcp"}, "/mcpServers/fixture/url"), ( {"args": ["--package=@attacker/mcp", "--", "safe"]}, "/mcpServers/fixture/args", @@ -1080,6 +1085,44 @@ def test_declaration_omission_is_deterministic(tmp_path: Path) -> None: assert contradictory_comparison.verdict == "unknown" assert "observation_state_contradictory" in {item.code for item in contradictory_comparison.findings} + not_attempted = unchanged.model_copy( + update={ + "attempted": False, + "decision": "not_applicable", + "outcome": "not_applicable", + } + ) + transient_effect = unchanged.model_copy( + update={ + "attempted": True, + "decision": "allowed", + "outcome": "succeeded", + } + ) + transient_filesystem = clean_unknown.model_copy( + update={ + "filesystem": transient_effect, + "database": not_attempted, + "network": NetworkEvidence(surface=not_attempted), + } + ) + transient_filesystem_comparison = compare_bill(_declaration(), transient_filesystem) + assert transient_filesystem_comparison.verdict == "block" + assert "undeclared_file_write_attempt" in {item.code for item in transient_filesystem_comparison.findings} + + transient_database = clean_unknown.model_copy( + update={ + "filesystem": not_attempted, + "database": transient_effect, + "network": NetworkEvidence(surface=not_attempted), + } + ) + transient_database_comparison = compare_bill(_declaration(), transient_database) + assert transient_database_comparison.verdict == "block" + assert "undeclared_database_write_attempt" in { + item.code for item in transient_database_comparison.findings + } + repo = _repo(tmp_path) trust_manifest = build_release_trust_manifest( repo, @@ -1941,7 +1984,10 @@ def test_tampering_and_wrong_commit_or_schema_are_detected(tmp_path: Path) -> No capsule_artifact["bytes"] = len(legacy_capsule_bytes) index_path.write_bytes(canonical_json_bytes(legacy_index)) legacy_result = verify_capsule(output) - assert legacy_result["valid"] is True, legacy_result + assert legacy_result["valid"] is False + assert {"subject_snapshot_missing", "subject_manifest_binding_missing"} <= { + item["code"] for item in legacy_result["errors"] + } capsule_path.write_bytes(original_capsule) index_path.write_bytes(original_index) wrong_root = verify_capsule(output, expect_root_sha256="0" * 64) From c527240be0d7297837b010172d1e6f564b2a7ab8 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 09:23:14 -0700 Subject: [PATCH 37/41] Separate transient file and database effects --- src/mcp_audit/proof_capsule.py | 2 +- src/mcp_audit/proof_observer.py | 10 +++++---- tests/test_proof_before_action.py | 37 +++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/mcp_audit/proof_capsule.py b/src/mcp_audit/proof_capsule.py index f6cd754..76a7b51 100644 --- a/src/mcp_audit/proof_capsule.py +++ b/src/mcp_audit/proof_capsule.py @@ -52,7 +52,7 @@ def compare_bill(declaration: ActionDeclaration, observation: Observation) -> Bi ) ) filesystem_attempt_without_persisted_change = ( - observation.filesystem.attempted is True and not observation.file_changes + observation.filesystem.attempted is True and not non_database_file_changes ) filesystem_effect_observed = ( bool(non_database_file_changes) or filesystem_attempt_without_persisted_change diff --git a/src/mcp_audit/proof_observer.py b/src/mcp_audit/proof_observer.py index a140413..26559a5 100644 --- a/src/mcp_audit/proof_observer.py +++ b/src/mcp_audit/proof_observer.py @@ -322,6 +322,8 @@ def observe_command( after_databases = _database_snapshot(collected) file_changes = _diff_files(before_files, after_files) database_changes = _diff_databases(before_databases, after_databases) + database_paths = {item.path for item in database_changes} + non_database_file_changes = [item for item in file_changes if item.path not in database_paths] network = _network_evidence( evidence / "network.before", evidence / "network.after", @@ -332,10 +334,10 @@ def observe_command( stdout = _read_bounded(evidence / "stdout") stderr = _read_bounded(evidence / "stderr") filesystem = SurfaceObservation( - attempted=True if file_changes else None, - decision="allowed" if file_changes else "unknown", - outcome="succeeded" if file_changes else "unknown", - persisted="changed" if file_changes else "unchanged", + attempted=True if non_database_file_changes else None, + decision="allowed" if non_database_file_changes else "unknown", + outcome="succeeded" if non_database_file_changes else "unknown", + persisted="changed" if non_database_file_changes else "unchanged", mechanism="complete before/after hash inventory of the disposable workspace", complete=False, limitations=[ diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index 74bb4ef..4d6ae48 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -517,6 +517,7 @@ def test_seeded_sqlite_mutation_is_semantically_detected(tmp_path: Path) -> None assert change.path == "seeded.db" assert change.change == "modified" assert change.changed_tables == ["items"] + assert observation.filesystem.attempted is None comparison = compare_bill(_declaration(), observation) assert "undeclared_database_write" in {item.code for item in comparison.findings} declared_database_write = _declaration( @@ -983,6 +984,7 @@ def test_loopback_network_attempt_is_detected_without_external_contact(tmp_path: def test_declaration_omission_is_deterministic(tmp_path: Path) -> None: from mcp_audit.proof_models import ( CommandEvidence, + DatabaseChange, FileChange, IsolationEvidence, NetworkEvidence, @@ -1123,6 +1125,41 @@ def test_declaration_omission_is_deterministic(tmp_path: Path) -> None: item.code for item in transient_database_comparison.findings } + database_file_change = FileChange( + path="seeded.db", + change="modified", + before_sha256="1" * 64, + after_sha256="2" * 64, + ) + database_change = DatabaseChange( + path="seeded.db", + change="modified", + before_sha256="1" * 64, + after_sha256="2" * 64, + changed_tables=["items"], + ) + transient_file_with_database_change = clean_unknown.model_copy( + update={ + "file_changes": [database_file_change], + "database_changes": [database_change], + "filesystem": transient_effect.model_copy(update={"complete": True}), + "database": transient_effect, + "network": NetworkEvidence(surface=not_attempted), + } + ) + database_only_declaration = _declaration( + destinations={"files": [], "databases": ["seeded.db"], "network": []}, + side_effects={"filesystem": "none", "database": "write", "network": "none"}, + ) + transient_file_with_database_comparison = compare_bill( + database_only_declaration, + transient_file_with_database_change, + ) + assert transient_file_with_database_comparison.verdict == "block" + assert "undeclared_file_write_attempt" in { + item.code for item in transient_file_with_database_comparison.findings + } + repo = _repo(tmp_path) trust_manifest = build_release_trust_manifest( repo, From 95e53cfc9901f57c23c9fadc88275957949d628b Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 09:33:06 -0700 Subject: [PATCH 38/41] Require trusted observer image identity --- CHANGELOG.md | 5 +- docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md | 4 +- docs/PROOF-BEFORE-ACTION.md | 14 ++- src/mcp_audit/proof_cli.py | 7 ++ src/mcp_audit/proof_observer.py | 20 +++- tests/test_proof_before_action.py | 122 +++++++++++++++++++---- 6 files changed, 145 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cea639..7f16763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,7 +70,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 validation instead of passing as Python integers. Raw remote URL controls and invalid ports become partial discovery diagnostics. Complete observers must treat transient filesystem/database attempts as effects, and verification - rejects any capsule missing either staged-subject binding. + rejects any capsule missing either staged-subject binding. Inspection now + requires an independently supplied exact image ID before image-provided + observer tools run, and IP-counter observations remain incomplete because + Unix-domain socket activity is not observed. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md index 981f617..b44c9d7 100644 --- a/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md +++ b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md @@ -87,11 +87,11 @@ platform. | Nested or very short-lived child processes | Final state quiesced; identity attribution incomplete | Surviving descendants are terminated before the final archive, but child executable identities and transient effects are not completely attributed. | | SQLite transactions with no final delta | Explicitly incomplete | Semantic comparison proves final content, not every query or transaction attempt, so the database surface cannot support `pass`. | | Non-SQLite databases | File-level only | Semantic records and remote database effects are unknown. | -| Network destination | Unobserved | IPv4/IPv6 IP and UDP counters plus family-agnostic Linux TCP counters reveal attempts, not the requested hostname or endpoint. Missing or regressed counters make the surface incomplete. | +| Network destination and Unix-domain sockets | Unobserved | IPv4/IPv6 IP and UDP counters plus family-agnostic Linux TCP counters reveal some attempts, not the requested hostname, endpoint, or Unix-domain socket activity. The network surface remains incomplete. | | Loopback inside the namespace | Available | A command can contact its own processes; the evidence marks attempts but does not call loopback external contact. | | Output links or special files | Fail-closed | Collection stops; the effect is not silently omitted and no completed capsule is issued. | | Unknown secret formats or low-entropy secret hashes | Residual risk | Redaction is best effort, and a digest can sometimes be guessed. Review declarations and commands before sharing capsules. | -| Malicious local Docker daemon or image | Trusted locally | A local image can contain hostile infrastructure. Pin and independently verify the image digest. | +| Malicious local Docker daemon or image | Caller-pinned, otherwise blocked | The CLI requires an independently sourced exact image ID and verifies the local resolution before image-provided observer tools run. The caller's trust record and Docker daemon integrity remain outside the capsule's proof. | | Internal capsule hashes | Consistency only | They do not prove who authorized the capsule. Record the index root in an external authority channel. | | mcp-trust grade applicability | Evidence-limited | Stale, masked, missing, version-unbound, dirty, ignored/untracked, or commit-mismatched evidence remains unknown. | | Producer build metadata | Evidence-limited | A clean embedded revision binds packaged code to its build source claim, but package authenticity still requires a trusted distribution channel or an externally anchored capsule root. | diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index 751fb85..386e10c 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -30,14 +30,18 @@ limitations: [] ``` Ensure the selected container image already exists locally. Proof Before Action -will not pull it. The mutable reference is resolved once and every subsequent -tool check and staging step uses that immutable image ID. Then inspect: +will not pull it. Obtain its exact `sha256:` image ID from an independent trusted +record; the CLI fails closed before any image-provided observer utility runs +unless the local resolution matches that supplied ID. The mutable reference is +resolved once and every subsequent tool check and staging step uses the matched +immutable image ID. Then inspect: ```console proof-before-action inspect \ --repo ./repository-under-review \ --declaration ./proof-before-action.yaml \ --trust-root ../mcp-trust \ + --expect-image-id "$TRUSTED_IMAGE_ID" \ --output ./proof-capsule \ -- node -e "require('fs').readFileSync('README.md')" ``` @@ -133,8 +137,10 @@ cannot observe transient create-delete, write-restore, or transaction attempts. Consequently a clean final snapshot is `unknown`, never proof of read-only behavior. IPv4/IPv6 IP and UDP counters plus Linux's family-agnostic TCP counters distinguish an observed attempt from no counter change, but cannot identify the -requested destination. Missing or regressed required counters make the network -surface incomplete. Link or special-file output blocks collection rather than +requested destination or observe Unix-domain sockets, including abstract +sockets. The network surface therefore remains incomplete even when every +required counter is available; missing or regressed counters add another +fail-closed reason. Link or special-file output blocks collection rather than silently disappearing. The command cannot write the observer-owned evidence tmpfs. diff --git a/src/mcp_audit/proof_cli.py b/src/mcp_audit/proof_cli.py index 4310c2b..8c6fe62 100644 --- a/src/mcp_audit/proof_cli.py +++ b/src/mcp_audit/proof_cli.py @@ -47,6 +47,11 @@ def main() -> None: type=click.Path(path_type=Path, exists=True, file_okay=False, readable=True), ) @click.option("--image", default="node:24-slim", show_default=True) +@click.option( + "--expect-image-id", + required=True, + help="Exact sha256 image ID obtained from an independent trusted record.", +) @click.option("--timeout", "timeout_seconds", default=45, type=click.IntRange(1, 600)) @click.option("--output", type=click.Path(path_type=Path), required=True) @click.argument("command", nargs=-1, type=click.UNPROCESSED, required=True) @@ -55,6 +60,7 @@ def inspect( declaration: Path, trust_root: Path | None, image: str, + expect_image_id: str, timeout_seconds: int, output: Path, command: tuple[str, ...], @@ -67,6 +73,7 @@ def inspect( repo, list(command), image=image, + expected_image_id=expect_image_id, timeout_seconds=timeout_seconds, ) if observed.subject_snapshot is None: diff --git a/src/mcp_audit/proof_observer.py b/src/mcp_audit/proof_observer.py index 26559a5..f06db9e 100644 --- a/src/mcp_audit/proof_observer.py +++ b/src/mcp_audit/proof_observer.py @@ -171,6 +171,7 @@ def observe_command( command: list[str], *, image: str, + expected_image_id: str | None = None, timeout_seconds: int = 45, ) -> Observation: if not command: @@ -195,6 +196,7 @@ def observe_command( before_databases = _database_snapshot(staged) _make_disposable_writable(staged) image_id = _local_image_id(image) + _verify_expected_image_id(image_id, expected_image_id) _require_image_tools(image_id) name = "pba-" + secrets.token_hex(8) runtime_image = name + "-input" @@ -716,6 +718,21 @@ def _local_image_id(image: str) -> str: return result.stdout.decode().strip() +def _verify_expected_image_id(resolved: str, expected: str | None) -> None: + if expected is None: + raise ObservationBlocked( + "an independently sourced --expect-image-id is required before image-provided " + "observer tools can run" + ) + if not re.fullmatch(r"sha256:[0-9a-f]{64}", expected): + raise ObservationBlocked("--expect-image-id must be an exact sha256 image ID") + if resolved != expected: + raise ObservationBlocked( + f"resolved image ID {resolved!r} does not match independently supplied " + f"--expect-image-id {expected!r}" + ) + + def _require_image_tools(image: str) -> None: result = _run( [ @@ -1132,10 +1149,11 @@ def _network_evidence( "per-container /proc/net/snmp and /proc/net/snmp6 counter deltas " "under Docker network mode none" ), - complete=True, + complete=False, limitations=[ "IPv4/IPv6 IP and UDP counters plus family-agnostic Linux TCP counters " "identify activity but not the requested destination.", + "Unix-domain socket activity, including abstract sockets, is not observed.", "Docker network mode none proves no ordinary external interface, not " "resistance to container escape.", ], diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index 4d6ae48..04d2b5b 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -48,6 +48,7 @@ _redact_argv, _stage_repository, _subject_snapshot_evidence, + _verify_expected_image_id, observe_command, ) from mcp_audit.proof_trust import build_release_trust_manifest @@ -80,6 +81,15 @@ def _declaration(**updates: object) -> ActionDeclaration: return ActionDeclaration.model_validate(payload) +def _node_image_id() -> str: + return subprocess.run( + ["docker", "image", "inspect", "--format", "{{.Id}}", "node:24-slim"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + def _repo(tmp_path: Path) -> Path: root = tmp_path / "repo" root.mkdir() @@ -87,6 +97,17 @@ def _repo(tmp_path: Path) -> Path: return root +def test_expected_image_id_is_required_and_exact() -> None: + resolved = "sha256:" + "a" * 64 + with pytest.raises(ObservationBlocked, match="independently sourced"): + _verify_expected_image_id(resolved, None) + with pytest.raises(ObservationBlocked, match="must be an exact sha256"): + _verify_expected_image_id(resolved, "node:24-slim") + with pytest.raises(ObservationBlocked, match="does not match independently supplied"): + _verify_expected_image_id(resolved, "sha256:" + "b" * 64) + _verify_expected_image_id(resolved, resolved) + + def _empty_trust(repo: Path, observation: Observation) -> ReleaseTrustManifest: return build_release_trust_manifest( repo, @@ -125,6 +146,8 @@ def time_out(*args: object, **kwargs: object) -> subprocess.CompletedProcess[byt str(repo), "--declaration", str(declaration), + "--expect-image-id", + "sha256:" + "a" * 64, "--output", str(tmp_path / "capsule"), "--", @@ -161,6 +184,8 @@ def test_cli_invalid_declaration_yaml_is_a_structured_inspection_block( str(repo), "--declaration", str(declaration), + "--expect-image-id", + "sha256:" + "a" * 64, "--output", str(tmp_path / "capsule"), "--", @@ -314,17 +339,22 @@ def skipping_fwalk( def test_read_only_final_state_is_unknown_and_deterministic(tmp_path: Path) -> None: repo = _repo(tmp_path) command = ["node", "-e", "require('fs').readFileSync('input.txt')"] - first = observe_command(repo, command, image="node:24-slim") - second = observe_command(repo, command, image="node:24-slim") + first = observe_command( + repo, + command, + image="node:24-slim", + expected_image_id=_node_image_id(), + ) + second = observe_command( + repo, + command, + image="node:24-slim", + expected_image_id=_node_image_id(), + ) assert first.file_changes == [] assert first.database_changes == [] assert first.network.surface.attempted is False - expected_image_id = subprocess.run( - ["docker", "image", "inspect", "--format", "{{.Id}}", "node:24-slim"], - check=True, - capture_output=True, - text=True, - ).stdout.strip() + expected_image_id = _node_image_id() assert first.isolation.image_reference == "node:24-slim" assert first.isolation.image_id == expected_image_id first_comparison = compare_bill(_declaration(), first) @@ -347,6 +377,7 @@ def test_transient_file_write_cannot_be_reported_as_read_only(tmp_path: Path) -> "const fs=require('fs');fs.writeFileSync('transient.txt','x');fs.unlinkSync('transient.txt')", ], image="node:24-slim", + expected_image_id=_node_image_id(), ) assert observation.file_changes == [] @@ -370,7 +401,12 @@ def test_rolled_back_database_write_cannot_be_reported_as_read_only(tmp_path: Pa "db.exec(\"BEGIN;UPDATE items SET value='transient' WHERE id=1;ROLLBACK;\");db.close()" ) - observation = observe_command(repo, ["node", "-e", code], image="node:24-slim") + observation = observe_command( + repo, + ["node", "-e", code], + image="node:24-slim", + expected_image_id=_node_image_id(), + ) assert observation.database_changes == [] assert observation.database.complete is False @@ -407,7 +443,12 @@ def test_background_descendant_cannot_mutate_after_observation_completion(tmp_pa "{detached:true,stdio:'ignore'}).unref()" ), ] - observation = observe_command(repo, command, image="node:24-slim") + observation = observe_command( + repo, + command, + image="node:24-slim", + expected_image_id=_node_image_id(), + ) assert observation.command.exit_code == 0 assert observation.command.timed_out is False assert observation.command.stdout_sha256 == sha256_bytes(b"") @@ -422,6 +463,7 @@ def test_command_timeout_still_emits_fail_closed_observation(tmp_path: Path) -> repo, ["node", "-e", "setTimeout(()=>{},10000)"], image="node:24-slim", + expected_image_id=_node_image_id(), timeout_seconds=1, ) assert observation.command.timed_out is True @@ -442,7 +484,12 @@ def test_command_is_unprivileged_and_cannot_rewrite_observer_evidence(tmp_path: "try{fs.writeFileSync('/pba/network.before','forged');process.exit(11)}" "catch(error){if(error.code!=='EACCES')process.exit(12)}" ) - observation = observe_command(repo, ["node", "-e", code], image="node:24-slim") + observation = observe_command( + repo, + ["node", "-e", code], + image="node:24-slim", + expected_image_id=_node_image_id(), + ) assert observation.command.exit_code == 0 assert observation.isolation.provider == "docker" assert observation.isolation.runtime_user == "65534:65534" @@ -470,7 +517,12 @@ def test_command_is_unprivileged_and_cannot_rewrite_observer_evidence(tmp_path: @requires_docker def test_option_like_command_is_not_consumed_by_setpriv(tmp_path: Path) -> None: repo = _repo(tmp_path) - observation = observe_command(repo, ["--help"], image="node:24-slim") + observation = observe_command( + repo, + ["--help"], + image="node:24-slim", + expected_image_id=_node_image_id(), + ) assert observation.command.exit_code not in {None, 0} comparison = compare_bill(_declaration(), observation) assert comparison.verdict == "block" @@ -484,6 +536,7 @@ def test_undeclared_file_write_is_detected_and_blocked(tmp_path: Path) -> None: repo, ["node", "-e", "require('fs').writeFileSync('created.txt','proof')"], image="node:24-slim", + expected_image_id=_node_image_id(), ) assert [(item.path, item.change) for item in observation.file_changes] == [("created.txt", "added")] comparison = compare_bill(_declaration(), observation) @@ -511,7 +564,12 @@ def test_seeded_sqlite_mutation_is_semantically_detected(tmp_path: Path) -> None "const db=new DatabaseSync('seeded.db');" "db.exec(\"UPDATE items SET value='after' WHERE id=1\");db.close()" ) - observation = observe_command(repo, ["node", "-e", code], image="node:24-slim") + observation = observe_command( + repo, + ["node", "-e", code], + image="node:24-slim", + expected_image_id=_node_image_id(), + ) assert len(observation.database_changes) == 1 change = observation.database_changes[0] assert change.path == "seeded.db" @@ -966,7 +1024,12 @@ def test_loopback_network_attempt_is_detected_without_external_contact(tmp_path: "const net=require('net');const s=net.connect(9,'127.0.0.1');" "s.on('error',()=>process.exit(0));setTimeout(()=>process.exit(0),500)" ) - observation = observe_command(repo, ["node", "-e", code], image="node:24-slim") + observation = observe_command( + repo, + ["node", "-e", code], + image="node:24-slim", + expected_image_id=_node_image_id(), + ) assert observation.network.surface.attempted is True assert observation.network.external_contact_count == 0 assert observation.network.counters["Tcp.ActiveOpens"] >= 1 @@ -1672,8 +1735,9 @@ def test_ipv6_network_counters_are_observed(tmp_path: Path) -> None: evidence = _network_evidence(before, after, before6, after6, timed_out=False) - assert evidence.surface.complete is True + assert evidence.surface.complete is False assert evidence.surface.attempted is True + assert "Unix-domain socket activity" in " ".join(evidence.surface.limitations) assert evidence.counters["Ip6.OutRequests"] == 1 assert evidence.counters["Udp6.OutDatagrams"] == 1 @@ -1704,11 +1768,17 @@ def test_ipv6_udp_attempt_is_detected_and_blocked(tmp_path: Path) -> None: "socket.send(Buffer.from('x'),9,'::1',error=>{socket.close();if(error)process.exit(2)})" ) - observation = observe_command(repo, ["node", "-e", code], image="node:24-slim") + observation = observe_command( + repo, + ["node", "-e", code], + image="node:24-slim", + expected_image_id=_node_image_id(), + ) assert observation.command.exit_code == 0 - assert observation.network.surface.complete is True + assert observation.network.surface.complete is False assert observation.network.surface.attempted is True + assert "Unix-domain socket activity" in " ".join(observation.network.surface.limitations) assert observation.network.counters["Udp6.OutDatagrams"] > 0 comparison = compare_bill(_declaration(), observation) assert comparison.verdict == "block" @@ -1986,7 +2056,12 @@ def test_installed_module_uses_embedded_build_provenance( def test_tampering_and_wrong_commit_or_schema_are_detected(tmp_path: Path) -> None: repo = _repo(tmp_path) declaration = _declaration() - observation = observe_command(repo, ["node", "-e", "process.exit(0)"], image="node:24-slim") + observation = observe_command( + repo, + ["node", "-e", "process.exit(0)"], + image="node:24-slim", + expected_image_id=_node_image_id(), + ) comparison = compare_bill(declaration, observation) capsule = build_capsule( declaration, @@ -2061,7 +2136,12 @@ def test_tampering_and_wrong_commit_or_schema_are_detected(tmp_path: Path) -> No def test_offline_html_escapes_untrusted_text(tmp_path: Path) -> None: repo = _repo(tmp_path) declaration = _declaration(name="") - observation = observe_command(repo, ["node", "-e", "process.exit(0)"], image="node:24-slim") + observation = observe_command( + repo, + ["node", "-e", "process.exit(0)"], + image="node:24-slim", + expected_image_id=_node_image_id(), + ) comparison = compare_bill(declaration, observation) capsule = build_capsule( declaration, @@ -2090,6 +2170,7 @@ def test_sensitive_repository_input_is_blocked_before_execution(tmp_path: Path) repo, ["node", "-e", "process.exit(0)"], image="node:24-slim", + expected_image_id=_node_image_id(), ) @@ -2121,6 +2202,7 @@ def test_literal_config_secret_and_sensitive_argv_are_redacted_or_blocked( repo, ["node", "-e", "process.exit(0)"], image="node:24-slim", + expected_image_id=_node_image_id(), ) assert _redact_argv( [ @@ -2217,6 +2299,8 @@ def test_cli_inspect_and_verify_the_portable_capsule(tmp_path: Path) -> None: str(repo), "--declaration", str(declaration), + "--expect-image-id", + _node_image_id(), "--output", str(output), "--", From 29c74750c0364677c16bcd2a6735acfede5454ac Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 09:43:08 -0700 Subject: [PATCH 39/41] Preserve staged executable identity --- CHANGELOG.md | 4 ++- docs/PROOF-BEFORE-ACTION.md | 7 ++--- src/mcp_audit/proof_observer.py | 43 ++++++++++++++++++++----------- tests/test_proof_before_action.py | 29 +++++++++++++++++++++ 4 files changed, 64 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f16763..9e9ec31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,7 +73,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 rejects any capsule missing either staged-subject binding. Inspection now requires an independently supplied exact image ID before image-provided observer tools run, and IP-counter observations remain incomplete because - Unix-domain socket activity is not observed. + Unix-domain socket activity is not observed. Staging preserves and binds Git + executable mode so the observed subject cannot silently differ from the + reviewed revision. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index 386e10c..ce544c8 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -107,9 +107,10 @@ The observer: copied but force `repository_dirty: true`, so the recorded subject commit is explicitly non-binding. Dependency discovery, a staged-tree hash, and the byte comparison with the recorded subject commit are captured from this same - immutable staged copy before execution. Files are opened relative to walked - directory descriptors with link following disabled, copied from that open - identity, and validated again from the private staged bytes. A directory + immutable staged copy before execution. Tracked executable mode is preserved + and included in that staged-tree/commit binding. Files are opened relative to + walked directory descriptors with link following disabled, copied from that + open identity, and validated again from the private staged bytes. A directory traversal or reopen failure blocks inspection instead of omitting a subtree; accepted directory listings are also reconciled with every directory actually traversed so a runtime-silent skip is detected; diff --git a/src/mcp_audit/proof_observer.py b/src/mcp_audit/proof_observer.py index f06db9e..e80591d 100644 --- a/src/mcp_audit/proof_observer.py +++ b/src/mcp_audit/proof_observer.py @@ -32,6 +32,8 @@ sha256_bytes, ) +_FileSnapshot = dict[str, tuple[str, str | None, bool | None]] + _IGNORED_NAMES = { ".DS_Store", ".coverage", @@ -477,6 +479,7 @@ def _stage_repository(source: Path, destination: Path) -> None: target, max_bytes=_MAX_INPUT_BYTES - total_bytes, ) + os.chmod(target, 0o755 if opened.st_mode & 0o111 else 0o644) total_bytes += copied_bytes _validate_staged_input(target, relative) finally: @@ -514,7 +517,7 @@ def _copy_open_repository_file( def _subject_snapshot_evidence( source: Path, staged: Path, - staged_tree: dict[str, tuple[str, str | None]], + staged_tree: _FileSnapshot, ) -> SubjectSnapshotEvidence: from mcp_audit.proof_trust import discover_repo_mcp @@ -553,7 +556,7 @@ def _subject_snapshot_evidence( capture_output=True, timeout=5, ).stdout - committed_tree: dict[str, tuple[str, str | None]] = {} + committed_tree: _FileSnapshot = {} for record in tree.split(b"\0"): if not record: continue @@ -563,16 +566,22 @@ def _subject_snapshot_evidence( relative = repository_path[len(prefix) :] if prefix else repository_path if repository_input_is_in_scope(Path(relative)): kind = "file" if object_type == "blob" and mode in {"100644", "100755"} else "other" - committed_tree[relative] = (kind, object_id if kind == "file" else None) + executable = mode == "100755" if kind == "file" else None + committed_tree[relative] = ( + kind, + object_id if kind == "file" else None, + executable, + ) parent = Path(relative).parent while parent != Path("."): - committed_tree[parent.as_posix()] = ("directory", None) + committed_tree[parent.as_posix()] = ("directory", None, None) parent = parent.parent dirty = set(staged_tree) != set(committed_tree) or any( - staged_tree[path][0] != committed_tree[path][0] for path in set(staged_tree) & set(committed_tree) + staged_tree[path][0] != committed_tree[path][0] or staged_tree[path][2] != committed_tree[path][2] + for path in set(staged_tree) & set(committed_tree) ) if not dirty: - for relative, (kind, expected_object_id) in committed_tree.items(): + for relative, (kind, expected_object_id, _executable) in committed_tree.items(): if kind != "file": continue value = (staged / relative).read_bytes() @@ -594,7 +603,11 @@ def _subject_snapshot_evidence( def _make_disposable_writable(root: Path) -> None: for path in root.rglob("*"): - os.chmod(path, 0o777 if path.is_dir() else 0o666) + if path.is_dir(): + mode = 0o777 + else: + mode = 0o777 if path.stat().st_mode & 0o111 else 0o666 + os.chmod(path, mode) os.chmod(root, 0o777) @@ -918,25 +931,25 @@ def _isolation_evidence(image: str, image_id: str, inspect: dict[str, Any]) -> I ) -def _file_snapshot(root: Path) -> dict[str, tuple[str, str | None]]: - snapshot: dict[str, tuple[str, str | None]] = {} +def _file_snapshot(root: Path) -> _FileSnapshot: + snapshot: _FileSnapshot = {} for path in sorted(root.rglob("*")): relative = path.relative_to(root).as_posix() mode = path.lstat().st_mode if stat.S_ISLNK(mode): - snapshot[relative] = ("symlink", None) + snapshot[relative] = ("symlink", None, None) elif stat.S_ISDIR(mode): - snapshot[relative] = ("directory", None) + snapshot[relative] = ("directory", None, None) elif stat.S_ISREG(mode): - snapshot[relative] = ("file", _sha256_file(path)) + snapshot[relative] = ("file", _sha256_file(path), bool(mode & 0o111)) else: - snapshot[relative] = ("other", None) + snapshot[relative] = ("other", None, None) return snapshot def _diff_files( - before: dict[str, tuple[str, str | None]], - after: dict[str, tuple[str, str | None]], + before: _FileSnapshot, + after: _FileSnapshot, ) -> list[FileChange]: changes: list[FileChange] = [] for path in sorted(set(before) | set(after)): diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index 04d2b5b..f0c28b3 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -1017,6 +1017,35 @@ def test_ignored_staged_subject_input_marks_the_commit_unbound( ) +def test_staged_subject_preserves_and_binds_executable_mode(tmp_path: Path) -> None: + repo = _repo(tmp_path) + script = repo / "run-fixture" + script.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + script.chmod(0o755) + subprocess.run(["git", "init", "-q", str(repo)], check=True) + subprocess.run(["git", "-C", str(repo), "config", "user.email", "proof@example.test"], check=True) + subprocess.run(["git", "-C", str(repo), "config", "user.name", "Proof Fixture"], check=True) + subprocess.run(["git", "-C", str(repo), "add", "."], check=True) + subprocess.run(["git", "-C", str(repo), "commit", "-qm", "fixture"], check=True) + staged = tmp_path / "staged" + staged.mkdir() + _stage_repository(repo, staged) + staged_tree = _file_snapshot(staged) + bound = _subject_snapshot_evidence(repo, staged, staged_tree) + + assert (staged / "run-fixture").stat().st_mode & 0o111 + assert staged_tree["run-fixture"][2] is True + assert bound.repository_dirty is False + + (staged / "run-fixture").chmod(0o644) + mode_changed_tree = _file_snapshot(staged) + mode_changed = _subject_snapshot_evidence(repo, staged, mode_changed_tree) + + assert mode_changed_tree["run-fixture"][2] is False + assert mode_changed.staged_tree_sha256 != bound.staged_tree_sha256 + assert mode_changed.repository_dirty is True + + @requires_docker def test_loopback_network_attempt_is_detected_without_external_contact(tmp_path: Path) -> None: repo = _repo(tmp_path) From c7b39c7d1c929bb80693b0d83bc931597cedebf0 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 09:50:53 -0700 Subject: [PATCH 40/41] Preserve runtime executable modes --- src/mcp_audit/proof_observer.py | 12 +++++++----- tests/test_proof_before_action.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/mcp_audit/proof_observer.py b/src/mcp_audit/proof_observer.py index e80591d..5e90e3c 100644 --- a/src/mcp_audit/proof_observer.py +++ b/src/mcp_audit/proof_observer.py @@ -98,7 +98,7 @@ _RUNTIME_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" _WRAPPER = r""" set -eu -cp -R /pba-input/. /workspace/ +cp -R --preserve=mode --no-preserve=ownership /pba-input/. /workspace/ chmod -R a+rwX /workspace cat /proc/net/snmp > /pba/network.before cat /proc/net/snmp6 > /pba/network6.before @@ -273,7 +273,7 @@ def observe_command( "--tmpfs", "/tmp:rw,noexec,nosuid,nodev,size=67108864,mode=1777", "--tmpfs", - "/workspace:rw,nosuid,nodev,size=536870912,mode=0777", + "/workspace:rw,exec,nosuid,nodev,size=536870912,mode=0777", "--tmpfs", "/pba:rw,noexec,nosuid,nodev,size=8388608,mode=0700", "--workdir", @@ -766,13 +766,14 @@ def _require_image_tools(image: str) -> None: image, "-c", "test -r /proc/net/snmp && test -r /proc/net/snmp6 && command -v tar >/dev/null " - "&& command -v timeout >/dev/null && command -v setpriv >/dev/null", + "&& command -v cp >/dev/null && command -v timeout >/dev/null " + "&& command -v setpriv >/dev/null", ], timeout=20, ) if result.returncode != 0: raise ObservationBlocked( - "local image lacks the required sh, tar, timeout, setpriv, or procfs observer" + "local image lacks the required sh, cp, tar, timeout, setpriv, or procfs observer" ) @@ -805,6 +806,7 @@ def _extract_observation_archive(value: bytes, destination: Path) -> None: target.parent.mkdir(parents=True, exist_ok=True) with target.open("wb") as output: shutil.copyfileobj(source_file, output) + os.chmod(target, 0o755 if member.mode & 0o111 else 0o644) else: raise ObservationBlocked( "runtime evidence contains a link or special file; collection stopped" @@ -879,7 +881,7 @@ def _isolation_evidence(image: str, image_id: str, inspect: dict[str, Any]) -> I expected_tmpfs = { "/pba": "rw,noexec,nosuid,nodev,size=8388608,mode=0700", "/tmp": "rw,noexec,nosuid,nodev,size=67108864,mode=1777", - "/workspace": "rw,nosuid,nodev,size=536870912,mode=0777", + "/workspace": "rw,exec,nosuid,nodev,size=536870912,mode=0777", } tmpfs_paths = sorted(tmpfs) if ( diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index f0c28b3..f970ae9 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -366,6 +366,24 @@ def test_read_only_final_state_is_unknown_and_deterministic(tmp_path: Path) -> N assert canonical_json_bytes(first_capsule) == canonical_json_bytes(second_capsule) +@requires_docker +def test_noop_observation_preserves_executable_mode(tmp_path: Path) -> None: + repo = _repo(tmp_path) + script = repo / "run-fixture" + script.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + script.chmod(0o755) + + observation = observe_command( + repo, + ["./run-fixture"], + image="node:24-slim", + expected_image_id=_node_image_id(), + ) + + assert observation.command.exit_code == 0 + assert observation.file_changes == [] + + @requires_docker def test_transient_file_write_cannot_be_reported_as_read_only(tmp_path: Path) -> None: repo = _repo(tmp_path) From 18dcf649a19437ef49f7f525cea911a720244a4d Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 10:00:36 -0700 Subject: [PATCH 41/41] Bound runtime evidence streaming --- CHANGELOG.md | 3 +- docs/PROOF-BEFORE-ACTION.md | 3 +- src/mcp_audit/proof_observer.py | 68 ++++++++++++++++++++++++++++--- tests/test_proof_before_action.py | 18 ++++++++ 4 files changed, 85 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e9ec31..df36767 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,7 +75,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 observer tools run, and IP-counter observations remain incomplete because Unix-domain socket activity is not observed. Staging preserves and binds Git executable mode so the observed subject cannot silently differ from the - reviewed revision. + reviewed revision, and the attached evidence archive streams to a bounded host + file instead of accumulating untrusted output in memory. - `pin_baseline_corrupted` warning code — a pin baseline file that exists but cannot be parsed now emits its own `ScanWarning` (naming the file and the parse error) instead of folding into `pin_baseline_missing`. A corrupted diff --git a/docs/PROOF-BEFORE-ACTION.md b/docs/PROOF-BEFORE-ACTION.md index ce544c8..e46aab2 100644 --- a/docs/PROOF-BEFORE-ACTION.md +++ b/docs/PROOF-BEFORE-ACTION.md @@ -127,7 +127,8 @@ The observer: starts, and validated into every observation; 4. terminates every surviving command descendant, verifies every Linux task is terminal from `/proc`, and only then streams one attached archive containing - the disposable workspace and root-owned evidence; + the disposable workspace and root-owned evidence to a host temporary file + under a hard byte cap; 5. collects file hashes, SQLite schema/row digests, and Linux IPv4/IPv6 IP/TCP/UDP counter deltas from that quiesced archive; 6. removes the container and temporary staging image. diff --git a/src/mcp_audit/proof_observer.py b/src/mcp_audit/proof_observer.py index 5e90e3c..8048546 100644 --- a/src/mcp_audit/proof_observer.py +++ b/src/mcp_audit/proof_observer.py @@ -3,11 +3,11 @@ from __future__ import annotations import hashlib -import io import json import os import re import secrets +import selectors import shutil import sqlite3 import stat @@ -15,6 +15,7 @@ import sys import tarfile import tempfile +import time from pathlib import Path, PurePosixPath from typing import Any, Literal, cast @@ -93,6 +94,7 @@ _TEXT_CONFIG_SUFFIXES = {".cfg", ".conf", ".ini", ".properties", ".toml", ".yaml", ".yml"} _MAX_FILES = 10_000 _MAX_INPUT_BYTES = 512 * 1024 * 1024 +_MAX_ARCHIVE_BYTES = _MAX_INPUT_BYTES + 32 * 1024 * 1024 _MAX_OUTPUT_BYTES = 256 * 1024 _MAX_TEXT_FILE_BYTES = 16 * 1024 * 1024 _RUNTIME_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" @@ -303,8 +305,10 @@ def observe_command( container_id = create.stdout.decode().strip() inspect = _inspect_container(container_id) isolation = _isolation_evidence(image, image_id, inspect) - attached = _run( + archive_path = root / "observation.tar" + attached = _run_bounded_archive( ["docker", "start", "--attach", container_id], + archive_path, timeout=timeout_seconds + 75, ) if attached.returncode != 0: @@ -312,7 +316,7 @@ def observe_command( f"observer wrapper failed with exit code {attached.returncode} " "before completing evidence collection: " + _safe_error(attached.stderr) ) - _extract_observation_archive(attached.stdout, collected_root) + _extract_observation_archive(archive_path, collected_root) if not (evidence / "complete").is_file(): raise ObservationBlocked("observer wrapper exited before completing evidence collection") command_runtime_profile = _read_command_runtime_profile(evidence / "command.status") @@ -777,11 +781,11 @@ def _require_image_tools(image: str) -> None: ) -def _extract_observation_archive(value: bytes, destination: Path) -> None: +def _extract_observation_archive(value: Path, destination: Path) -> None: file_count = 0 total_bytes = 0 try: - with tarfile.open(fileobj=io.BytesIO(value), mode="r:") as archive: + with tarfile.open(value, mode="r:") as archive: for member in archive: relative = PurePosixPath(member.name) parts = tuple(part for part in relative.parts if part != ".") @@ -1240,6 +1244,60 @@ def _run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[bytes] raise ObservationBlocked(f"Docker command timed out after {timeout} seconds") from exc +def _run_bounded_archive( + argv: list[str], + output: Path, + *, + timeout: int, + max_bytes: int = _MAX_ARCHIVE_BYTES, +) -> subprocess.CompletedProcess[bytes]: + process = subprocess.Popen( + argv, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env={"PATH": os.environ.get("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")}, + ) + if process.stdout is None or process.stderr is None: + process.kill() + process.wait() + raise ObservationBlocked("Docker archive command pipes were unavailable") + selector = selectors.DefaultSelector() + selector.register(process.stdout, selectors.EVENT_READ, "stdout") + selector.register(process.stderr, selectors.EVENT_READ, "stderr") + deadline = time.monotonic() + timeout + written = 0 + stderr = bytearray() + try: + with output.open("xb") as archive: + while selector.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise ObservationBlocked(f"Docker command timed out after {timeout} seconds") + for key, _events in selector.select(min(remaining, 0.25)): + chunk = os.read(key.fd, 64 * 1024) + if not chunk: + selector.unregister(key.fileobj) + continue + if key.data == "stdout": + if written + len(chunk) > max_bytes: + raise ObservationBlocked("runtime evidence archive exceeded the host byte limit") + archive.write(chunk) + written += len(chunk) + elif len(stderr) < _MAX_OUTPUT_BYTES: + stderr.extend(chunk[: _MAX_OUTPUT_BYTES - len(stderr)]) + returncode = process.wait(timeout=max(deadline - time.monotonic(), 0.001)) + return subprocess.CompletedProcess(argv, returncode, b"", bytes(stderr)) + except subprocess.TimeoutExpired as exc: + raise ObservationBlocked(f"Docker command timed out after {timeout} seconds") from exc + finally: + selector.close() + if process.poll() is None: + process.kill() + process.wait() + process.stdout.close() + process.stderr.close() + + def _cleanup_docker_resource(argv: list[str], *, timeout: int) -> str | None: try: result = _run(argv, timeout=timeout) diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index f970ae9..edd9449 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -46,6 +46,7 @@ _file_snapshot, _network_evidence, _redact_argv, + _run_bounded_archive, _stage_repository, _subject_snapshot_evidence, _verify_expected_image_id, @@ -108,6 +109,23 @@ def test_expected_image_id_is_required_and_exact() -> None: _verify_expected_image_id(resolved, resolved) +def test_runtime_archive_is_capped_before_host_allocation(tmp_path: Path) -> None: + output = tmp_path / "archive.tar" + with pytest.raises(ObservationBlocked, match="exceeded the host byte limit"): + _run_bounded_archive( + [ + sys.executable, + "-c", + "import sys; sys.stdout.buffer.write(b'x' * 4096)", + ], + output, + timeout=5, + max_bytes=1024, + ) + + assert output.stat().st_size <= 1024 + + def _empty_trust(repo: Path, observation: Observation) -> ReleaseTrustManifest: return build_release_trust_manifest( repo,