Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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: 38 additions & 0 deletions .github/workflows/docker-policy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Enforce baseline container hygiene: digest-pinned base image and non-root USER.
name: Docker policy

on:
push:
branches: [main, master]
paths:
- "Dockerfile"
- "docker/**"
pull_request:
paths:
- "Dockerfile"
- "docker/**"

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- name: Verify Dockerfiles use digest-pinned FROM and non-root USER
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
files=(Dockerfile docker/*/Dockerfile)
for f in "${files[@]}"; do
echo "Checking $f"
if ! grep -qE '^FROM [^[:space:]]+@sha256:[a-f0-9]{64}' "$f"; then
echo "ERROR: $f must use FROM image@sha256:<64-hex-digest>" >&2
exit 1
fi
if ! grep -qE '^USER[[:space:]]' "$f"; then
echo "ERROR: $f must end with a non-root USER directive" >&2
exit 1
fi
done
echo "PASS: all Dockerfiles pinned and non-root"
228 changes: 228 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
# Pinned action SHAs are immutable; update intentionally when upgrading.
# yamllint disable rule:line-length
name: Publish Node Wire package

# Manual trigger: go to Actions → "Publish Node Wire package" → Run workflow.
# package_path must match the allowlist below (prevents confused-deputy path abuse).
#
# Examples:
# package_path: packages/runtime
# package_path: packages/connectors/fhir_epic
# package_path: packages/connectors/google_drive
on:
workflow_dispatch:
inputs:
package_path:
description: |
Relative path to the package directory (must be allowlisted).
Examples: packages/runtime | packages/connectors/fhir_epic
required: true
type: string
version:
description: "Semver version to publish (e.g. 0.2.0)"
required: true
type: string

env:
PIP_AUDIT_VERSION: "2.7.3"
CYCLONEDX_BOM_VERSION: "4.6.1"

jobs:
# ─────────────────────────────────────────────────────────────────────────────
# Build a binary wheel for each platform (Linux / macOS / Windows).
# cibuildwheel compiles Cython extensions and produces manylinux / macosx /
# win_amd64 wheels. The NoPyBuild override in setup.py ensures .py source
# files are excluded from all wheels.
# ─────────────────────────────────────────────────────────────────────────────
build-wheels:
name: Build (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
permissions:
contents: read

steps:
- name: Validate package path (allowlist)
shell: python
run: |
import os
import sys

raw = "${{ inputs.package_path }}".strip().replace("\\", "/")
norm = os.path.normpath(raw).replace("\\", "/")
# Reject traversal / absolute paths
if norm.startswith("..") or os.path.isabs(raw):
print("ERROR: invalid package_path", file=sys.stderr)
sys.exit(1)
allowed = {
"packages/runtime",
"packages/connectors/http_generic",
"packages/connectors/stripe",
"packages/connectors/smtp",
"packages/connectors/google_drive",
"packages/connectors/fhir_cerner",
"packages/connectors/fhir_epic",
}
if norm not in allowed:
print(f"ERROR: package_path {norm!r} is not allowlisted.", file=sys.stderr)
print("Allowed:", sorted(allowed), file=sys.stderr)
sys.exit(1)
print(f"PASS: package_path {norm!r} is allowlisted")

- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- name: Set up Python
uses: actions/setup-python@0b93645e9fea7318ecaed2b3599a311e5c0f207c # v5.3.0
with:
python-version: "3.11"

- name: Install build tools
run: python -m pip install --upgrade pip "cython>=3.0" "cibuildwheel>=2.16.0"

- name: Build platform wheel(s)
run: |
cd "${{ inputs.package_path }}"
python -m cibuildwheel --output-dir dist
env:
# Build only Python 3.11+ wheels (matches requires-python in pyproject.toml)
CIBW_BUILD: "cp311-* cp312-*"
# Skip 32-bit targets and PyPy — not supported
CIBW_SKIP: "*-win32 *-manylinux_i686 pp*"

# ── Security gate: verify no .py source files leaked into any wheel ──────
- name: Verify binary-only wheel (no .py source)
shell: python
run: |
import glob, sys, zipfile

wheels = glob.glob("${{ inputs.package_path }}/dist/*.whl")
if not wheels:
print("ERROR: No wheels produced", file=sys.stderr)
sys.exit(1)

leaked: dict[str, list[str]] = {}
for whl in wheels:
with zipfile.ZipFile(whl) as zf:
bad = [n for n in zf.namelist() if n.endswith(".py")]
if bad:
leaked[whl] = bad

if leaked:
print("SECURITY FAIL: .py files found in wheel(s):", file=sys.stderr)
for whl, files in leaked.items():
print(f" {whl}:", file=sys.stderr)
for f in files:
print(f" {f}", file=sys.stderr)
sys.exit(1)

print(f"PASS: {len(wheels)} wheel(s) verified — no .py source files")

- name: Record wheel SHA256 (artifact integrity)
shell: python
run: |
import glob, hashlib, pathlib, sys
dist = pathlib.Path("${{ inputs.package_path }}") / "dist"
wheels = sorted(dist.glob("*.whl"))
if not wheels:
print("ERROR: no wheels to hash", file=sys.stderr)
sys.exit(1)
lines = []
for w in wheels:
h = hashlib.sha256(w.read_bytes()).hexdigest()
line = f"{h} {w.name}"
print(line)
lines.append(line)
(dist / "sha256sums.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")

- name: Upload wheel artifacts
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: wheels-${{ matrix.os }}
path: ${{ inputs.package_path }}/dist/*.whl
if-no-files-found: error

# ─────────────────────────────────────────────────────────────────────────────
# Collect all platform wheels, run supply-chain checks, publish to PyPI.
# Uses Trusted Publisher (OIDC) — no long-lived PyPI API tokens needed.
# Configure on PyPI: Settings → Publishing → Add Publisher (GitHub, this repo,
# workflow name = "Publish Node Wire package").
# ─────────────────────────────────────────────────────────────────────────────
publish:
name: Publish to PyPI
needs: build-wheels
runs-on: ubuntu-latest
permissions:
id-token: write # Required for Trusted Publisher OIDC
contents: read

steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- name: Set up Python
uses: actions/setup-python@0b93645e9fea7318ecaed2b3599a311e5c0f207c # v5.3.0
with:
python-version: "3.11"

- name: Download all wheel artifacts
uses: actions/download-artifact@fa0a91b85d4f404e442e0f254de474a18c628458 # v4.1.8
with:
path: dist-all

- name: Flatten into dist/ directory
run: |
mkdir -p dist
find dist-all -name "*.whl" -exec cp {} dist/ \;
echo "Wheels collected:"
ls dist/
sha256sum dist/*.whl | tee dist/sha256sums.txt

- name: Validate wheel version matches input
shell: python
run: |
import glob, sys

expected = "${{ inputs.version }}"
wheels = glob.glob("dist/*.whl")
if not wheels:
print("ERROR: No wheels found for publish", file=sys.stderr)
sys.exit(1)

bad = [w for w in wheels if f"-{expected}-" not in w and f"-{expected.replace('.', '_')}-" not in w]
if bad:
print(f"ERROR: Version mismatch. Expected {expected!r} in filename.", file=sys.stderr)
for w in bad:
print(f" {w}", file=sys.stderr)
sys.exit(1)

print(f"PASS: {len(wheels)} wheel(s) match version {expected!r}")

- name: Install built wheels for CVE scan
run: pip install dist/*.whl

- name: Vulnerability scan (CVE gate — blocks publish on HIGH or higher)
run: |
pip install "pip-audit==${{ env.PIP_AUDIT_VERSION }}"
pip-audit --fail-on HIGH

- name: Generate SBOM
run: |
pip install "cyclonedx-bom==${{ env.CYCLONEDX_BOM_VERSION }}"
cyclonedx-py environment -o sbom.json
echo "SBOM generated: sbom.json"

- name: Upload SBOM as release artifact
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: sbom-${{ inputs.version }}
path: sbom.json

- name: Publish to PyPI (Trusted Publisher / OIDC + Sigstore attestations)
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4
with:
packages-dir: dist/
# attestations: true generates a Sigstore attestation automatically.
# Clients can verify with: pip download <pkg> && python -m pypi_attestation_viewer <whl>
attestations: true
21 changes: 20 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,23 @@ __pycache__/
dist/
.env
.DS_Store
**/.DS_Store
**/.DS_Store
.coverage
.coverage.*
htmlcov/

# GCP / cloud credentials
connectorplatform-*.json
*-service-account.json
*credentials*.json

# Grafana exports (auto-generated)
grafana/*.json

# Python lock files
uv.lock

# Temporary test/upload files
upload-test.txt
*-test.txt
upload-*.txt
40 changes: 32 additions & 8 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Node Wire — Docker Image
# ========================
# This image packages the connector platform as a FastMCP server.
# This image packages the connector platform as an MCP stdio server (manifest-driven).
# ToolHive runs it as a container, injects secrets as env vars,
# and proxies the stdio MCP transport to HTTP/SSE.
#
Expand All @@ -11,7 +11,8 @@
# thv run --name node-wire-connectors --transport stdio \
# --secret ... node-wire:latest

FROM python:3.12-slim
# Digest-pinned base (update when bumping tag). See .github/workflows/docker-policy.yml.
FROM python:3.12-slim@sha256:3d5ed973e45820f5ba5e46bd065bd88b3a504ff0724d85980dcd05eab361fcf4

# Install system deps needed by some connector libs
RUN apt-get update && apt-get install -y --no-install-recommends \
Expand All @@ -21,19 +22,42 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app

# Copy source (build context = repo root)
COPY pyproject.toml ./
COPY src/ ./src/
COPY config/ ./config/

# Install platform + agents extras
RUN pip install --no-cache-dir -e ".[agents]"
COPY packages/runtime/dist/*.whl /wheels/
COPY packages/connectors/http_generic/dist/*.whl /wheels/
COPY packages/connectors/stripe/dist/*.whl /wheels/
COPY packages/connectors/smtp/dist/*.whl /wheels/
COPY packages/connectors/google_drive/dist/*.whl /wheels/
COPY packages/connectors/fhir_cerner/dist/*.whl /wheels/
COPY packages/connectors/fhir_epic/dist/*.whl /wheels/

ENV PYTHONPATH=/app/src

# Install runtime + connector packages using local wheel artifacts
RUN pip install --no-cache-dir --find-links=/wheels \
node-wire-runtime \
node-wire-http-generic \
node-wire-stripe \
node-wire-smtp \
node-wire-google-drive \
node-wire-fhir-cerner \
node-wire-fhir-epic \
"mcp>=1.6.0" \
&& rm -rf /wheels

RUN groupadd --system --gid 1000 app \
&& useradd --system --uid 1000 --gid app --home /app app \
&& chown -R app:app /app

USER app

# Expose nothing — ToolHive manages the stdio proxy port internally
# MCP_PORT / FASTMCP_PORT will be set by ToolHive if ever needed

# Healthcheck: verify the package is importable
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s CMD \
python -c "from agents.mcp_entrypoint import _make_server; print('ok')" || exit 1
python -c "from agents.mcp_entrypoint import main; assert callable(main); print('ok')" || exit 1

# Default entrypoint: run the FastMCP server on stdio
# Default entrypoint: run the MCP server on stdio
CMD ["python", "-m", "agents.mcp_entrypoint"]
Loading