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/CHANGELOG.md b/CHANGELOG.md index 8f002e6..df36767 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,74 @@ 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. + 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. + 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`. + 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. 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. No-follow, + directory-relative source descriptors close validation-to-copy link races, + 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 + current evidence; snapshot chronology and network-isolation proof also fail + 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. 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. Contradictory complete-surface fields also remain + non-passing, and the custom PEP 517 requirement hooks delegate correctly to + 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. 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. 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. Command + 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. Malformed repository command, URL, + 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. Remote + paths preserve trailing slashes, and Boolean trust schema versions fail shape + 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. 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. Staging preserves and binds Git + executable mode so the observed subject cannot silently differ from the + 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/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..67a7479 100644 --- a/docs/OUTPUT-CONTRACT.md +++ b/docs/OUTPUT-CONTRACT.md @@ -100,6 +100,52 @@ 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. +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. +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. +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 +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..b44c9d7 --- /dev/null +++ b/docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md @@ -0,0 +1,119 @@ +# 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 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 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 + +- 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, + `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 + 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. +- 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. +- 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. +- 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, and a + directory-walk reopen failure or silently skipped accepted directory blocks + rather than producing a partial tree. + +## 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`. | +| 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 | 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 | 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 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 | 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. | + +## False claims the product must not make + +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. + +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 + +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..e46aab2 --- /dev/null +++ b/docs/PROOF-BEFORE-ACTION.md @@ -0,0 +1,238 @@ +# 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. 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')" +``` + +`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 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; +- `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. 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. +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 +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 + +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; 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. 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. 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; +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 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. + +File and SQLite comparisons are complete for persisted regular files that can be +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 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. + +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. + +## Release trust manifest + +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 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 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 +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. +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. Public remote paths retain +their exact trailing-slash semantics, and Boolean values never satisfy integer +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. +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 +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 + +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. + +Legacy observation-v1 capsules emitted before staged subject evidence was added +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 +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/mcp_audit_build_backend.py b/mcp_audit_build_backend.py new file mode 100644 index 0000000..ff7b882 --- /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 as uv_get_requires_for_build_sdist + + 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 as uv_get_requires_for_build_wheel + + 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 as uv_get_requires_for_build_editable + + return list(uv_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 c407e0d..01bb48a 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). @@ -60,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 new file mode 100644 index 0000000..76a7b51 --- /dev/null +++ b/src/mcp_audit/proof_capsule.py @@ -0,0 +1,659 @@ +"""Actual-vs-declared comparison, deterministic capsule export, and verification.""" + +from __future__ import annotations + +import fnmatch +import hmac +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, + SurfaceObservation, + 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 + 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( + code="undeclared_tool", + severity="error", + message=f"observed executable {executable!r} is not declared", + evidence=[executable], + ) + ) + filesystem_attempt_without_persisted_change = ( + 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 + ) + 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" + if non_database_file_changes + else "undeclared_file_write_attempt" + ), + severity="error", + 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], + ) + ) + outside = [ + item.path + 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) + ] + if outside: + findings.append( + ComparisonFinding( + code="expanded_file_destination", + severity="error", + message="observed file changes expanded beyond declared destinations", + evidence=outside, + ) + ) + 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" + if observation.database_changes + else "undeclared_database_write_attempt" + ), + severity="error", + 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], + ) + ) + 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", + ) + ) + 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", + ) + ) + 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) + 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 _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, + comparison: BillComparison, + trust_manifest: ReleaseTrustManifest, +) -> EvidenceCapsule: + subject = observation.subject_snapshot + if subject is None: + raise ValueError("new capsules require staged subject snapshot evidence") + 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: + 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, + provenance_source=provenance_source, + ), + limitations=limitations, + ) + return EvidenceCapsule( + payload=payload, + integrity=CapsuleIntegrity(payload_sha256=sha256_bytes(canonical_json_bytes(payload))), + ) + + +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") + 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

    + +

    Declaration comparison

    +

    Release trust manifest

    + +{trust_rows}
    ConfigIdentityEvidence stateGrade
    +

    Limitations and unknowns

    + +""" + + +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: + raw_index = json.loads(index_bytes) + 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_index_bytes != 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: + 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_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_capsule_bytes != 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"}) + 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( + { + "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( + { + "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}", + } + ) + 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", + "message": f"expected {expect_root_sha256}, got {root_sha256}", + } + ) + return { + "valid": not errors, + "root_sha256": root_sha256, + "authority": "anchored" if root_matches else "unverified", + "errors": errors, + } + + +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, + 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), "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_cli.py b/src/mcp_audit/proof_cli.py new file mode 100644 index 0000000..8c6fe62 --- /dev/null +++ b/src/mcp_audit/proof_cli.py @@ -0,0 +1,161 @@ +"""Standalone Proof Before Action command-line interface.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import click +import yaml +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( + "--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) +def inspect( + repo: Path, + declaration: Path, + trust_root: Path | None, + image: str, + expect_image_id: 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, + expected_image_id=expect_image_id, + 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, + 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: + 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..e1d9f23 --- /dev/null +++ b/src/mcp_audit/proof_models.py @@ -0,0 +1,473 @@ +"""Versioned contracts for the local-first Proof Before Action product.""" + +from __future__ import annotations + +import hashlib +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 + +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 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", "docker-in-colima"] = "docker" + 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) + 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): + 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 = 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): + surface: SurfaceObservation + counters: dict[str, int] = Field(default_factory=dict) + external_contact_count: Literal[0] = 0 + + +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 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 | None = None + 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", + "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 enforce_cross_field_consistency(self) -> TrustEvidence: + 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") + 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") + 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 + + +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 + + @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 + 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) + 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: + 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") + 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 + + +class ProducerEvidence(StrictModel): + name: Literal["mcp-audits"] = "mcp-audits" + version: str + commit: str | None + dirty: bool | None + provenance_source: Literal["build-metadata", "source-checkout"] | None = 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 _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") + 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..8048546 --- /dev/null +++ b/src/mcp_audit/proof_observer.py @@ -0,0 +1,1320 @@ +"""Disposable command observation with no host mounts or forwarded secrets.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import secrets +import selectors +import shutil +import sqlite3 +import stat +import subprocess +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, + NetworkEvidence, + Observation, + SubjectSnapshotEvidence, + SurfaceObservation, + canonical_json_bytes, + sha256_bytes, +) + +_FileSnapshot = dict[str, tuple[str, str | None, bool | None]] + +_IGNORED_NAMES = { + ".DS_Store", + ".coverage", + ".git", + ".mypy_cache", + ".nox", + ".serena", + ".tox", + ".venv", + "htmlcov", + "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" +) +_HOME_PATH = re.compile(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_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" +_WRAPPER = r""" +set -eu +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 +ulimit -f 512 +set +e +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 +cat /proc/net/snmp6 > /pba/network6.after +printf '%s\n' "$rc" > /pba/exit-code +touch /pba/complete +tar -C / -cf - workspace pba +""" + + +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, + expected_image_id: str | None = None, + 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-")) + staged = root / "staged" + collected_root = root / "collected" + collected = collected_root / "workspace" + evidence = collected_root / "pba" + staged.mkdir(mode=0o700) + collected_root.mkdir(mode=0o700) + container_id: str | None = None + staging_container_id: str | None = None + runtime_image: str | None = None + try: + 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) + _verify_expected_image_id(image_id, expected_image_id) + _require_image_tools(image_id) + name = "pba-" + secrets.token_hex(8) + runtime_image = name + "-input" + stage_create = _run( + [ + "docker", + "create", + "--name", + name + "-stage", + "--network", + "none", + "--entrypoint", + "/bin/true", + image_id, + ], + 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) + ) + 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 + create = _run( + [ + "docker", + "create", + "--name", + name, + "--network", + "none", + "--read-only", + "--cap-drop", + "ALL", + "--cap-add", + "KILL", + "--cap-add", + "SETGID", + "--cap-add", + "SETPCAP", + "--cap-add", + "SETUID", + "--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,exec,nosuid,nodev,size=536870912,mode=0777", + "--tmpfs", + "/pba:rw,noexec,nosuid,nodev,size=8388608,mode=0700", + "--workdir", + "/workspace", + "--user", + "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", + committed_image_id, + "-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) + archive_path = root / "observation.tar" + attached = _run_bounded_archive( + ["docker", "start", "--attach", container_id], + archive_path, + 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(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") + 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 = _file_snapshot(collected) + 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", + evidence / "network6.before", + evidence / "network6.after", + timed_out=timed_out, + ) + stdout = _read_bounded(evidence / "stdout") + stderr = _read_bounded(evidence / "stderr") + filesystem = SurfaceObservation( + 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=[ + "Persisted regular-file state is completely compared, but 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=False, + limitations=[ + "Only copied SQLite files receive semantic inspection; other databases " + "remain file-level evidence.", + "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) + return Observation( + subject_snapshot=subject_snapshot, + isolation=isolation, + command=CommandEvidence( + argv=recorded_argv, + argv_sha256=recorded_argv_sha256, + 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: + cleanup_errors: list[str] = [] + if container_id: + if error := _cleanup_docker_resource(["docker", "rm", "-f", container_id], timeout=20): + cleanup_errors.append(error) + if staging_container_id: + if error := _cleanup_docker_resource(["docker", "rm", "-f", staging_container_id], timeout=20): + cleanup_errors.append(error) + if runtime_image: + 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]) + + +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 + expected_directories = {"."} + traversed_directories: set[str] = set() + 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) + traversed_directories.add(relative_directory.as_posix()) + 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) + expected_directories.add(relative.as_posix()) + (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, + ) + os.chmod(target, 0o755 if opened.st_mode & 0o111 else 0o644) + total_bytes += copied_bytes + _validate_staged_input(target, relative) + 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: + 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) + + +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, + staged_tree: _FileSnapshot, +) -> 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: _FileSnapshot = {} + 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" + 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, None) + parent = parent.parent + dirty = set(staged_tree) != set(committed_tree) or any( + 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, _executable) 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("*"): + 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) + + +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(_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: + 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()}") + 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: + 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 _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( + [ + "docker", + "run", + "--rm", + "--network", + "none", + "--read-only", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--entrypoint", + "/bin/sh", + "--env", + f"PATH={_RUNTIME_PATH}", + image, + "-c", + "test -r /proc/net/snmp && test -r /proc/net/snmp6 && command -v tar >/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, cp, tar, timeout, setpriv, or procfs observer" + ) + + +def _extract_observation_archive(value: Path, destination: Path) -> None: + file_count = 0 + total_bytes = 0 + try: + 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 != ".") + 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") + 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) + os.chmod(target, 0o755 if member.mode & 0o111 else 0o644) + 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 _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: + 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", [])} + 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", "")) + 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 = {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,exec,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 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 != expected_tmpfs + ): + 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 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 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", + observer_capabilities=["KILL", "SETGID", "SETPCAP", "SETUID"], + ) + + +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, None) + elif stat.S_ISDIR(mode): + snapshot[relative] = ("directory", None, None) + elif stat.S_ISREG(mode): + snapshot[relative] = ("file", _sha256_file(path), bool(mode & 0o111)) + else: + snapshot[relative] = ("other", None, None) + return snapshot + + +def _diff_files( + before: _FileSnapshot, + after: _FileSnapshot, +) -> 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, + 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, + 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) + old6 = _parse_snmp6(before6) + new6 = _parse_snmp6(after6) + ipv4_keys = ( + ("Tcp", "ActiveOpens"), + ("Tcp", "PassiveOpens"), + ("Tcp", "AttemptFails"), + ("Udp", "OutDatagrams"), + ("Ip", "OutRequests"), + ) + 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."], + ) + ) + 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( + 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 and /proc/net/snmp6 counter deltas " + "under Docker network mode none" + ), + 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.", + ], + ), + 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 _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()) + 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]: + 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 _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) + 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/src/mcp_audit/proof_trust.py b/src/mcp_audit/proof_trust.py new file mode 100644 index 0000000..38960e6 --- /dev/null +++ b/src/mcp_audit/proof_trust.py @@ -0,0 +1,1181 @@ +"""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, cast +from urllib.parse import urlsplit, urlunsplit + +from mcp_audit.proof_models import ( + DependencyOccurrence, + DiscoveryDiagnostic, + ReleaseTrustManifest, + SubjectSnapshotEvidence, + 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, + *, + subject_snapshot: SubjectSnapshotEvidence | None = None, +) -> ReleaseTrustManifest: + root = repo.resolve() + 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( + 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, + repository_staged_tree_sha256=staged_tree_sha256, + 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( + trust_root.resolve(), + dependencies, + diagnostics, + repository_commit=commit, + repository_dirty=dirty, + repository_staged_tree_sha256=staged_tree_sha256, + ) + + +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 + 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( + 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"/{server_map_key}/{_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 + 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" + if package_json.is_file(): + try: + package_payload = json.loads(package_json.read_text(encoding="utf-8")) + 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 + 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( + 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: + 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 + if "mcp" not in spec.lower(): + continue + name, version, exact = _parse_pypi_spec(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")) + 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 + 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( + DiscoveryDiagnostic( + source_path="server.json", + source_pointer=f"/packages/{index}", + code="invalid_entry", + message="package descriptor must be an object", + ) + ) + continue + 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" + 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_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"{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=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=runtime_hint, + 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 = cast(list[str], config.get("args", [])) + 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(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, + 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 _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") + 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): + 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") + 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 + + +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( + 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", + "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, + repository_staged_tree_sha256, + f"mcp-trust source is incomplete: {', '.join(sorted(missing))}", + ) + try: + 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): + return _unknown_trust_manifest( + dependencies, + 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 _input_bytes_match_git_commit( + trust_root, + trust_commit, + {files[name]: value for name, value in input_bytes.items()}, + ) + ) + snapshot_generated_at = str(snapshot.get("generated_at", "")) or "unknown" + evaluated_at = datetime.now(UTC).date().isoformat() + "T23:59:59.999999+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(input_bytes[name]) for name in sorted(files)}, + 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 + ] + 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" + ) + 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( + 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 " + "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.") + 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, + repository_staged_tree_sha256=repository_staged_tree_sha256, + discovery_coverage="partial" if diagnostics else "complete", + dependencies=dependencies, + diagnostics=diagnostics, + trust_source=source, + entries=entries, + limitations=limitations, + ) + + +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 + if ( + not _valid_schema_version(snapshot.get("schema_version")) + or not isinstance(snapshot.get("generated_at"), str) + or not _valid_schema_version(spec_shift.get("format_version")) + or not isinstance(spec_shift.get("servers"), dict) + ): + return False + 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 _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 + 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 + 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, +) -> 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]], + 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 = cast(str, candidates[0]["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 = cast(dict[str, Any], record["sandbox"]) + network: Literal["verified_none", "unknown", "not_applicable"] = ( + "not_applicable" + if sandbox.get("mode") == "not_applicable" + else "verified_none" + if record.get("scan_mode") == "mcpaudit-local-network-off" + and sandbox.get("mode") == "docker" + and sandbox.get("network") == "none" + else "unknown" + ) + if network != "verified_none": + state = "unverifiable" if state == "current" else state + 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", + slug=slug, + 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, + ) + + +def _unknown_trust_manifest( + dependencies: list[DependencyOccurrence], + 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, + 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: 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): + 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]: + 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] + 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 _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: + 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: + 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 ( + 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 + except ValueError: + private = host in {"localhost"} or host.endswith(".local") + if private: + return "sha256:" + hashlib.sha256(url.encode()).hexdigest() + netloc = host.lower() + 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 + 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 _input_bytes_match_git_commit( + root: Path, + commit: str, + inputs: dict[Path, bytes], +) -> bool: + for path, loaded_bytes in inputs.items(): + 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 != loaded_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: + limitations.append("Subject repository commit is UNKNOWN; release evidence is not commit-bound.") + 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." + ) + 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..edd9449 --- /dev/null +++ b/tests/test_proof_before_action.py @@ -0,0 +1,2393 @@ +"""End-to-end acceptance coverage for Proof Before Action.""" + +from __future__ import annotations + +import hashlib +import json +import os +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, + 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, + TrustEvidence, + canonical_json_bytes, + sha256_bytes, +) +from mcp_audit.proof_observer import ( + ObservationBlocked, + _cleanup_docker_resource, + _cleanup_local_root, + _command_argv_evidence, + _file_snapshot, + _network_evidence, + _redact_argv, + _run_bounded_archive, + _stage_repository, + _subject_snapshot_evidence, + _verify_expected_image_id, + 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 _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() + (root / "input.txt").write_text("stable\n", encoding="utf-8") + 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 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, + None, + subject_snapshot=observation.subject_snapshot, + ) + + +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), + "--expect-image-id", + "sha256:" + "a" * 64, + "--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 + + +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), + "--expect-image-id", + "sha256:" + "a" * 64, + "--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: + 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" + + +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) + + +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")) + + +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_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", + 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 = _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) + second_comparison = compare_bill(_declaration(), second) + 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_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) + observation = observe_command( + repo, + [ + "node", + "-e", + "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 == [] + 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", + expected_image_id=_node_image_id(), + ) + + 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) + (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", + 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"") + assert observation.file_changes == [] + assert compare_bill(_declaration(), observation).verdict == "unknown" + + +@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", + expected_image_id=_node_image_id(), + 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", + expected_image_id=_node_image_id(), + ) + 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 == [ + "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 == "unknown" + + +@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", + expected_image_id=_node_image_id(), + ) + 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) + observation = observe_command( + 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) + 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"}, + ) + 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 +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", + expected_image_id=_node_image_id(), + ) + 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"] + 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( + 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 == "unknown" + assert "observation_incomplete" in {item.code for item in declared_comparison.findings} + 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 == [] + assert [(item.source_pointer, item.code, item.message) for item in manifest.diagnostics] == [ + ( + "/packages/0/transport", + "invalid_entry", + "package transport must be an object", + ) + ] + + +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" + + +@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"), + ( + {"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", + ), + ( + {"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( + 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} + + +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", + }, + "trailing": {"url": "https://gateway.example/mcp/"}, + } + } + ), + 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"] + assert identities["trailing"] == "https://gateway.example/mcp/" + assert identities["trailing"] != identities["base"] + + +@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_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( + 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 + + +@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: + 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_stage = tmp_path / "excluded-stage" + excluded_stage.mkdir() + _stage_repository(repo, excluded_stage) + + 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, + subject_snapshot=subject_snapshot, + ) + + 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." + in manifest.limitations + ) + + +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) + 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", + 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 + 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(tmp_path: Path) -> None: + from mcp_audit.proof_models import ( + CommandEvidence, + DatabaseChange, + FileChange, + IsolationEvidence, + NetworkEvidence, + SurfaceObservation, + ) + + unchanged = SurfaceObservation( + attempted=None, + decision="unknown", + outcome="unknown", + persisted="unchanged", + mechanism="fixture", + 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, + 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=sha256_bytes(canonical_json_bytes(["node"])), + 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) + + 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} + + 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} + + 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 + } + + 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, + 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=sha256_bytes(canonical_json_bytes(["node"])), + 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), + ) + 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) + 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"]} + + 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 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, +) -> 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" + (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" + ) + _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( + 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") + _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" + 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 + + +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" + + 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", + [ + "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 + + +@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" + (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"] = 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") + + 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 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 + + +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", + expected_image_id=_node_image_id(), + ) + + assert observation.command.exit_code == 0 + 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" + 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( + '{"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 + ) + + +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( + '{"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 + ) + + +@pytest.mark.parametrize( + ("relative", "payload"), + [ + ("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", []), + ], +) +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 + + +@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"), + [ + ("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, +) -> 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) + declaration = _declaration() + 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, + 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 + 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 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) + 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, + 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 + index = json.loads(original_index) + index["subject_commit"] = "0" * 40 + 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"]} + index_path.write_bytes(original_index) + payload = bytearray(capsule_path.read_bytes()) + payload[len(payload) // 2] ^= 1 + 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"]} + + +@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", + expected_image_id=_node_image_id(), + ) + comparison = compare_bill(declaration, observation) + 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") + 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", + expected_image_id=_node_image_id(), + ) + + +@pytest.mark.parametrize( + ("config_name", "secret_config"), + [ + ( + ".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, config_name: str, secret_config: str +) -> None: + repo = _repo(tmp_path) + (repo / config_name).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", + expected_image_id=_node_image_id(), + ) + assert _redact_argv( + [ + "tool", + "--header", + "Authorization: Bearer private-value", + "--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: + 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) + (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") + (repo / ".github/workflows").mkdir(parents=True) + (repo / ".github/workflows/publish.yml").write_text( + "permissions:\n" + " id-token: write\n" + "steps:\n" + " persist-credentials: false\n" + " token: ${{ secrets.TOKEN }}\n", + encoding="utf-8", + ) + 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), + "--expect-image-id", + _node_image_id(), + "--output", + str(output), + "--", + "node", + "-e", + "process.exit(0)", + ], + ) + assert inspected.exit_code == 1, inspected.output + receipt = json.loads(inspected.output) + assert receipt["ok"] is False + assert receipt["verdict"] == "unknown" + 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" 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,