Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
1110577
feat: add Proof Before Action workflow
saagpatel Jul 19, 2026
9ba1245
test: restore portable Proof Before Action gates
saagpatel Jul 19, 2026
860cd99
fix: ignore macOS metadata during proof staging
saagpatel Jul 19, 2026
7b2366b
fix: exclude generated local metadata from proof input
saagpatel Jul 19, 2026
1e47949
fix: distinguish GitHub OIDC permissions from secrets
saagpatel Jul 19, 2026
fb94dba
fix: parse credential assignments without source false positives
saagpatel Jul 19, 2026
bf51748
fix: redact private argv paths and restore Linux CI
saagpatel Jul 19, 2026
6a920e2
fix: honor database-only declarations and malformed transports
saagpatel Jul 19, 2026
ab64774
Handle Docker observer timeouts safely
saagpatel Jul 19, 2026
df52cf4
Fail closed on unconfirmed observer cleanup
saagpatel Jul 19, 2026
1a5696b
Harden disposable observer final-state capture
saagpatel Jul 19, 2026
752bd4c
Fail closed on dirty trust evidence
saagpatel Jul 19, 2026
cd6ec4b
Bind trust and producer evidence to source
saagpatel Jul 19, 2026
1a41b18
Keep runtime and trust evidence honest
saagpatel Jul 19, 2026
7168a16
Keep malformed inputs structured
saagpatel Jul 19, 2026
c2d4d95
Bind trust evidence to actual config pointers
saagpatel Jul 19, 2026
16727ed
Keep ignored subject inputs unbound
saagpatel Jul 19, 2026
c20d47f
Bind evidence to the staged subject snapshot
saagpatel Jul 19, 2026
0492623
Close staging races without breaking v1
saagpatel Jul 19, 2026
137e666
Fail closed on partial repository walks
saagpatel Jul 19, 2026
fca3950
Detect silently skipped repository subtrees
saagpatel Jul 19, 2026
95f2f9a
Validate nested trust evidence fields
saagpatel Jul 19, 2026
f9cc23a
Fail closed on trust chronology and network unknowns
saagpatel Jul 19, 2026
b1652f2
Make unobservable effects fail closed
saagpatel Jul 19, 2026
3dda384
Reject contradictory trust isolation evidence
saagpatel Jul 19, 2026
fa253e9
Bind observations to immutable evidence
saagpatel Jul 19, 2026
5b0151c
Fix build hooks and surface consistency
saagpatel Jul 19, 2026
683de4d
Fail closed on malformed trust evidence
saagpatel Jul 19, 2026
5588240
Bind trust evidence to complete occurrences
saagpatel Jul 19, 2026
3c9de82
Recompute capsule semantic bindings
saagpatel Jul 19, 2026
78e2875
Bind commands and canonical capsule bytes
saagpatel Jul 19, 2026
b0ba8bc
Reject coerced capsule scalar types
saagpatel Jul 19, 2026
74dbc3b
Reject malformed repository trust inputs
saagpatel Jul 19, 2026
93aefce
Prevent trust identity aliasing
saagpatel Jul 19, 2026
62aece8
Preserve exact remote trust paths
saagpatel Jul 19, 2026
38fb470
Fail closed on transient and unbound evidence
saagpatel Jul 19, 2026
c527240
Separate transient file and database effects
saagpatel Jul 19, 2026
95e53cf
Require trusted observer image identity
saagpatel Jul 19, 2026
29c7475
Preserve staged executable identity
saagpatel Jul 19, 2026
c7b39c7
Preserve runtime executable modes
saagpatel Jul 19, 2026
18dcf64
Bound runtime evidence streaming
saagpatel Jul 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
68 changes: 68 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions docs/OUTPUT-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
119 changes: 119 additions & 0 deletions docs/PROOF-BEFORE-ACTION-THREAT-MODEL.md
Original file line number Diff line number Diff line change
@@ -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.
Loading