Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
38 changes: 30 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,26 +29,29 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: '1.23'
cache-dependency-path: broker/go.sum
cache-dependency-path: |
broker/go.sum
mcp/go.sum
fleet/go.sum
# A cross-compile, because a missing go.sum entry can be
# platform-specific: chacha20poly1305 imports x/sys/cpu on amd64 and not
# on arm64, so `go mod tidy` on an Apple Silicon machine recorded a go.sum
# that built locally and failed inside the linux/amd64 Docker build. This
# catches it in the fast job, in about a second.
- name: cross-compile for the deployment target
run: GOOS=linux GOARCH=amd64 go build ./broker/...
run: GOOS=linux GOARCH=amd64 go build ./broker/... ./mcp/... ./fleet/...

- name: go vet
run: go vet ./broker/...
run: go vet ./broker/... ./mcp/... ./fleet/...
- name: go test
run: go test -race -count=1 ./broker/...
run: go test -race -count=1 ./broker/... ./mcp/... ./fleet/...
# v7+ is required: golangci-lint v2 uses a config schema that v6 of the
# action refuses to parse ("golangci-lint v2 is not supported by
# golangci-lint-action v6").
- uses: golangci/golangci-lint-action@v7
with:
version: v2.13.1
args: ./broker/... --timeout=5m
args: ./broker/... ./mcp/... ./fleet/... --timeout=5m

integration:
name: broker (postgres)
Expand All @@ -58,23 +61,39 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: '1.23'
cache-dependency-path: broker/go.sum
cache-dependency-path: |
broker/go.sum
mcp/go.sum
fleet/go.sum
- name: postgres up
run: docker compose up -d --wait postgres
# The SQL guard's design is to use Postgres's own planner, so a guard
# tested against a mocked planner would be testing the mock — which is
# exactly the gap the design exists to close.
- name: integration tests
run: go test -race -tags=integration -count=1 ./broker/...
run: go test -race -tags=integration -count=1 ./broker/... ./mcp/... ./fleet/...
- name: postgres logs on failure
if: failure()
run: docker compose logs --no-color postgres

python:
name: harness (python)
runs-on: ubuntu-latest
env:
# The kit example and the fleet service are Go programs the Python suite builds
# and runs. Locally a missing toolchain skips those tests; here it must fail
# them, or "the build scans the example on every commit" would quietly stop
# being true.
SENTINEL_REQUIRE_GO: "1"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.23"
cache-dependency-path: |
broker/go.sum
mcp/go.sum
fleet/go.sum
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
Expand All @@ -84,7 +103,10 @@ jobs:
- name: mypy
run: uv run mypy
- name: pytest (unit)
run: uv run pytest tests -m unit -q
run: uv run pytest tests -m unit -q --junitxml=pytest-report.xml
- name: failing tests, as annotations
if: failure()
run: python3 scripts/ci_annotate.py pytest-report.xml

e2e:
name: end-to-end
Expand Down
89 changes: 87 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Publishes the harness to PyPI as `sentinel-mcp-conformance` when a GitHub
# release is published. Uses PyPI trusted publishing, so no token is stored:
# On a GitHub release this publishes the wheel to PyPI as `sentinel-mcp-conformance`,
# pushes a multi-arch container image to GHCR, signs both with cosign (keyless, using
# this workflow's GitHub OIDC identity, so there is no signing key to leak) and
# attaches a CycloneDX SBOM of the harness to the release.
#
# Verify a release:
# cosign verify ghcr.io/patsypppe/sentinel:<version> \
# --certificate-identity-regexp 'https://github.com/patsypppe/sentinel/.github/workflows/release.yml@.*' \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com
#
# PyPI: Uses PyPI trusted publishing, so no token is stored:
# the one-time setup is adding this repository and workflow as a trusted
# publisher on https://pypi.org/manage/account/publishing/ (environment: pypi).

Expand Down Expand Up @@ -57,3 +66,79 @@ jobs:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1

image:
name: container image, signed
needs: build
if: github.event_name == 'release'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: sigstore/cosign-installer@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
run: |
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- id: push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
provenance: true
tags: |
ghcr.io/${{ github.repository_owner }}/sentinel:${{ steps.meta.outputs.version }}
ghcr.io/${{ github.repository_owner }}/sentinel:latest
- name: sign the image by digest
env:
DIGEST: ${{ steps.push.outputs.digest }}
run: cosign sign --yes "ghcr.io/${{ github.repository_owner }}/sentinel@${DIGEST}"
- name: the image runs
run: |
docker run --rm "ghcr.io/${{ github.repository_owner }}/sentinel:${{ steps.meta.outputs.version }}" version

attest:
name: sign the wheel, attach the SBOM
needs: build
if: github.event_name == 'release'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: sigstore/cosign-installer@v3
- name: sign each distribution file
run: |
for f in dist/*; do
cosign sign-blob --yes --bundle "$f.sigstore.json" "$f"
done
- name: SBOM of the harness
uses: anchore/sbom-action@v0
with:
path: harness
format: cyclonedx-json
output-file: sentinel-harness.cdx.json
upload-artifact: false
upload-release-assets: false
- name: attach to the release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release upload "$GITHUB_REF_NAME" dist/*.sigstore.json sentinel-harness.cdx.json --clobber
32 changes: 32 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Hooks for teams that scan a locally-running MCP server before they push.
#
# repos:
# - repo: https://github.com/patsypppe/sentinel
# rev: v0.4.0
# hooks:
# - id: sentinel-scan
#
# Point it at a server with a sentinel.toml in your repository (scan.endpoint) or
# the SENTINEL_ENDPOINT environment variable. It runs on pre-push by default: a
# scan makes a few hundred requests and is not something to run on every commit.

# Runs the published image. Needs Docker, nothing else, and cannot drift from the
# version pinned by `rev`.
- id: sentinel-scan
name: sentinel MCP conformance scan
description: Grade a locally-running MCP server against MCP 2026-07-28 and fail on a MUST violation.
entry: ghcr.io/patsypppe/sentinel:latest scan --gate must --no-color
language: docker_image
pass_filenames: false
always_run: true
stages: [pre-push, manual]

# For machines that already have `sentinel` installed (pip, pipx or uvx).
- id: sentinel-scan-local
name: sentinel MCP conformance scan (installed)
description: Same scan, using the sentinel command already on PATH.
entry: sentinel scan --gate must --no-color
language: system
pass_filenames: false
always_run: true
stages: [pre-push, manual]
90 changes: 90 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Changelog

## 0.4.0

The harness stops being a one-shot grader and becomes something a team runs.

### Added: operating it

- **`sentinel.toml`**: endpoint, gate, timeouts, rule toggles, severity overrides, waivers.
Precedence is CLI > `SENTINEL_*` > file > `extends` > default, tested cell by cell, and every
resolved value records its source in the report.
- **Waivers** with a required `expires_at`. A malformed date is an error; an expired waiver stops
applying and the finding fails again, labelled.
- **Severity overrides that can only tighten.** Demoting a MUST is rejected.
- **Baselines** (`baseline create`, `baseline prune`, `scan --baseline`): the gate fires only on
new findings, and the report shows what was fixed. Finding fingerprints are
`sha256(rule, target, evidence key)` and never contain a request id or timestamp.
- **`snapshot` / `diff`**: rug-pull detection. A rewritten tool description, a widened scope or a
dropped read-only promise is high severity; `diff --gate` exits 1 on those.
- **Gray-box evidence** for the five unverifiable MUSTs: a wrong-audience token, a second
principal, an egress capture, an effect counter, an audit verifier. Commands run with
`shell=False`, a timeout and a minimal environment; `--no-exec` disables them. Absence of
evidence is never a pass.
- **Fleet scans** (`scan --targets`): named, separately credentialed targets, each judged by its
own gate, with a rollup. An unreachable target is an error for that target, never a pass.
- **Formats**: `markdown` (size-capped PR comment), `junit`, `html` (self-contained), JSON schema 2,
SARIF with fingerprints and suppressions. `--also FORMAT=PATH` writes several from one scan.
- Rules run **concurrently**, each on its own connection; the report is identical regardless of
completion order.

### Added: 17 rules

`x-mcp-header` constraints (5), `tools-declare-valid-input-schema`, tool-name hygiene (2), tools
capability agreement, `tampered-requeststate-rejected`, `input-required-carries-requests-or-state`,
`input-requests-respect-declared-capabilities`, and the beyond-spec `SENTINEL/SECURITY/`
`tool-description-injection`, `wildcard-scopes`, `manifest-drift`, `tls-posture` and
`SENTINEL/OPS/latency-budget`. A third fixture, `partial`, exercises every `NOT_APPLICABLE` branch.

Eleven of the new `MCP/` rules cite a page, not a heading anchor; see `docs/CITATIONS.md`.

### Added: distribution

Docker image (non-root, multi-arch in the release workflow), GitHub Action inputs for config,
baseline, snapshot, fleet and `--no-exec` with a Markdown job summary, a GitLab template that
publishes JUnit, pre-commit hooks, cosign keyless signing of the image and wheel, and a CycloneDX
SBOM on each release.

### Added: the Go side

- **`mcp/`**: the reusable packages extracted from `broker/internal/` into a module of their own,
with `server.New`, `server.InsecureAnonymous`, an in-memory audit sink, and `mcp/example`, a
one-tool server the build scans with `--gate must` and `--gate security`.
- **`fleet/` (`sentinel-server`)**: ingest, history, posture, drift, expiring waivers, an API and a
server-rendered dashboard with a strict CSP. Memory or Postgres behind one contract suite.
`scan --publish` sends a report and never changes the exit code.

### Changed

- JSON reports are schema 2 (additive over 1; `--schema-version 1` still works).
- `transport.NewServer` takes `transport.Settings` instead of the broker's configuration struct.
- Unit tests run in parallel: 94 s to about 30 s.
- Measured scan wall-clock is now 68 rules in about 260 ms (it was 35 rules in 62 ms, one at a
time, in one process). The measurement runs the fixture in its own process; sharing one
interpreter measured the two fighting over a GIL.

### Fixed

- `baseline create` and `prune` take `--target-name`; a finding's identity includes it, so a
baseline could not otherwise match a target scanned by name.

### Not done

- `handles`, `mrtr` and the Postgres audit sink still carry their pgx code in the same packages;
splitting them behind storage interfaces into `mcp/postgres` is the next step.
- Citations for eleven rules are page-level (see above).
- The OTLP exporter is not wired into the broker.
- No release has been cut; PyPI trusted publishing and the GHCR image still need a first release.

## 0.3.0

`--safe`, `registry-scan`, SSE-framed response parsing, the GitHub Action, PyPI packaging.

## 0.2.0

Rule lifecycle (`deprecated_in`, `superseded_by`), three severity corrections, the beyond-spec
`SENTINEL/` namespace, error codes moved out of the range the specification retired.

## 0.1.0

The MVP: the broker, the harness, the oracle fixtures, the deprecation inventory.
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ the non-conformant fixture server.

| File | Sole authority for |
|---|---|
| `broker/internal/envelope/errors.go` | every error code |
| `broker/internal/handles/resolve.go` | making a handle usable |
| `broker/internal/authz/audience.go` | accepting a token |
| `mcp/envelope/errors.go` | every error code |
| `mcp/handles/resolve.go` | making a handle usable |
| `mcp/authz/audience.go` | accepting a token |

## Hard constraints

Expand Down
28 changes: 28 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# The scanner as a container: `docker run ghcr.io/patsypppe/sentinel scan --endpoint ...`.
#
# Two stages, so the image carries the installed wheel and nothing that built it:
# no compiler, no pip cache, no source tree. It runs as an unprivileged user, and
# the only network call it makes is to the endpoint it is given.
#
# `python:3.12-slim` rather than a distroless base: the harness needs a Python 3.12
# runtime, and the distroless Python images are older. Slim plus a non-root user,
# nothing else installed, is the honest description of what this is.

FROM python:3.12-slim AS build
WORKDIR /src
COPY harness/ /src/harness/
RUN pip install --no-cache-dir build \
&& python -m build --wheel --outdir /dist /src/harness

FROM python:3.12-slim
LABEL org.opencontainers.image.source="https://github.com/patsypppe/sentinel" \
org.opencontainers.image.description="Conformance harness for MCP 2026-07-28" \
org.opencontainers.image.licenses="MIT"
RUN useradd --uid 10001 --no-create-home --shell /usr/sbin/nologin sentinel
COPY --from=build /dist/*.whl /tmp/
RUN pip install --no-cache-dir /tmp/*.whl && rm -f /tmp/*.whl
USER 10001
WORKDIR /work
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
ENTRYPOINT ["sentinel"]
CMD ["--help"]
13 changes: 13 additions & 0 deletions Dockerfile.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.git
.venv
**/__pycache__
**/.mypy_cache
**/.ruff_cache
**/.pytest_cache
broker/
site/
docs/
tests/
fixtures/
deploy/
envoy/
Loading
Loading