diff --git a/.github/workflows/attest.yml b/.github/workflows/attest.yml index a2a11d3..49b9fa1 100644 --- a/.github/workflows/attest.yml +++ b/.github/workflows/attest.yml @@ -16,6 +16,11 @@ on: schedule: - cron: "17 4 * * *" workflow_dispatch: + inputs: + max_targets: + description: "Attest at most N pending targets (0 = all). Use a small N for a smoke run." + required: false + default: "0" permissions: contents: read @@ -38,9 +43,11 @@ jobs: with: python-version: "3.12" - id: plan + env: + MAX_TARGETS: ${{ inputs.max_targets || '0' }} run: | pip install --quiet "pyyaml==6.0.3" - m=$(python3 tools/plan.py --attester "$ATTESTER_ID") + m=$(python3 tools/plan.py --attester "$ATTESTER_ID" --limit "$MAX_TARGETS") n=$(printf '%s' "$m" | python3 -c 'import json,sys;print(len(json.load(sys.stdin)))') echo "matrix=$m" >> "$GITHUB_OUTPUT" echo "count=$n" >> "$GITHUB_OUTPUT" diff --git a/targets.yaml b/targets.yaml index 2e26ccd..3ef345a 100644 --- a/targets.yaml +++ b/targets.yaml @@ -66,7 +66,8 @@ targets: - node - /cache/npm/@modelcontextprotocol__server-redis/node_modules/.bin/mcp-server-redis - redis://127.0.0.1:6379 - note: connects at startup + note: connects to Redis at startup (ECONNREFUSED under --network none); cannot list a surface without a live backend + skip: true - coordinate: npm:@modelcontextprotocol/server-everart@0.6.2 spawn: - node @@ -129,6 +130,8 @@ targets: - /cache/npm/@stripe__mcp/node_modules/.bin/mcp - --tools=all - --api-key=sk_test_DUMMY + skip: true + note: never completes the MCP handshake in the sandbox (30 s); needs investigation before attesting - coordinate: npm:@sentry/mcp-server@0.39.0 spawn: - node @@ -174,7 +177,8 @@ targets: - /cache/npm/mcp-server-kubernetes/node_modules/.bin/mcp-server-kubernetes env: KUBECONFIG: /tmp/kubeconfig - note: may need a cluster + note: crashes without a reachable cluster/kubeconfig + skip: true - coordinate: npm:@agentdeskai/browser-tools-mcp@2.0.2 spawn: - node @@ -202,6 +206,8 @@ targets: - from mcp_server_sqlite import main; main() - --db-path - /tmp/test.db + skip: true + note: "incompatible with mcp 2.x (Server.list_resources removed) \u2014 what uvx resolves today also crashes" - coordinate: pypi:mcp-server-sentry@2025.1.14 spawn: - python3 @@ -209,6 +215,8 @@ targets: - from mcp_server_sentry import main; main() - --auth-token - DUMMY + skip: true + note: incompatible with mcp 2.x (McpError import) - coordinate: pypi:mcp-atlassian@0.23.1 spawn: - python3 @@ -226,6 +234,8 @@ targets: - python3 - -c - from mcp_server_tree_sitter.server import main; main() + skip: true + note: incompatible with mcp 2.x (mcp.server.fastmcp renamed) - coordinate: pypi:arxiv-mcp-server@0.7.2 spawn: - python3 @@ -249,6 +259,8 @@ targets: env: QDRANT_URL: http://127.0.0.1:6333 COLLECTION_NAME: x + skip: true + note: resolves a hostname at startup (fastembed model fetch) under --network none; cannot list a surface offline - coordinate: pypi:duckduckgo-mcp-server@0.6.1 spawn: - python3 @@ -259,14 +271,15 @@ targets: - python3 - -c - from mcp_server_docker import main; main() - note: needs a docker socket + note: requires Python >= 3.12; attester image is 3.11 + skip: true - coordinate: pypi:mcp-server-motherduck@1.0.8 spawn: - python3 - -c - from mcp_server_motherduck import main; main() - --db-path - - ':memory:' + - /tmp/attest.duckdb - coordinate: pypi:mcp-neo4j-cypher@0.6.0 spawn: - python3 diff --git a/tools/attest_one.sh b/tools/attest_one.sh index bd54d69..af8053b 100755 --- a/tools/attest_one.sh +++ b/tools/attest_one.sh @@ -8,7 +8,7 @@ set -euo pipefail : "${TARGET_JSON:?TARGET_JSON (one plan.py entry) is required}" : "${IMAGE:?IMAGE is required}" OUT="${OUT:-$PWD/out}"; CACHE="${CACHE:-$PWD/cache}" -mkdir -p "$OUT" "$CACHE" +mkdir -p "$OUT" "$CACHE" "$CACHE/.tmp" # stage-1 scratch is disk-backed: dependency trees can exceed any sane tmpfs coord=$(printf '%s' "$TARGET_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin)["coordinate"])') lock_rel=$(printf '%s' "$TARGET_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin)["lock_rel"])') mapfile -t spawn < <(printf '%s' "$TARGET_JSON" | python3 -c 'import json,sys;[print(a) for a in json.load(sys.stdin)["spawn"]]') @@ -21,22 +21,26 @@ echo "== [$coord] stage 1: pre-fetch (network on, --ignore-scripts / --only-bina case "$eco" in npm) docker run --rm --network bridge --user "$uid:$gid" -e HOME=/tmp -e npm_config_cache=/tmp/npm-cache \ - --tmpfs /tmp:rw,nosuid,size=256m -v "$CACHE:/cache" "$IMAGE" \ + -v "$CACHE/.tmp:/tmp" -v "$CACHE:/cache" "$IMAGE" \ npm install --prefix "/cache/npm/$segment" --ignore-scripts --no-audit --no-fund --no-package-lock --loglevel=error "$name@$version" ;; pypi) docker run --rm --network bridge --user "$uid:$gid" -e HOME=/tmp \ - --tmpfs /tmp:rw,nosuid,size=256m -v "$CACHE:/cache" "$IMAGE" \ + -v "$CACHE/.tmp:/tmp" -e TMPDIR=/tmp -v "$CACHE:/cache" "$IMAGE" \ pip install --quiet --no-cache-dir --only-binary :all: --target "/cache/pypi/$segment" "$name==$version" ;; *) echo "unknown ecosystem $eco" >&2; exit 2 ;; esac echo "== [$coord] stage 2: sandboxed capture (network none, non-root, read-only, cap-drop ALL)" -envflags=(); for kv in "${envkv[@]:-}"; do [ -n "$kv" ] && envflags+=(-e "$kv"); done +# The MCP SDK spawns stdio servers with a MINIMAL default environment (HOME, PATH, +# USER…), not the parent's — so the target's env, and PYTHONPATH for PyPI targets, are +# delivered by wrapping the spawn in `env K=V …` inside the sandbox. The lock records +# `env` as the command; the surface digest consensus compares excludes `server`. +wrap=(env "HOME=/tmp" "PYTHONDONTWRITEBYTECODE=1" "PYTHONPATH=/cache/pypi/$segment" "NODE_PATH=/cache/npm/$segment/node_modules") +for kv in "${envkv[@]:-}"; do [ -n "$kv" ] && wrap+=("$kv"); done docker run --rm --network none --user "$uid:$gid" --read-only --cap-drop ALL \ --security-opt no-new-privileges --pids-limit 256 --memory 512m \ - --tmpfs /tmp:rw,noexec,nosuid,size=64m -e HOME=/tmp -e PYTHONDONTWRITEBYTECODE=1 \ - -e "PYTHONPATH=/cache/pypi/$segment" -e "NODE_PATH=/cache/npm/$segment/node_modules" "${envflags[@]}" \ + --tmpfs /tmp:rw,noexec,nosuid,size=64m -e HOME=/tmp \ -v "$CACHE:/cache:ro" -v "$OUT:/out" "$IMAGE" \ - timeout 120 mcp-warden pin --lock "/out/$lock_rel" -- "${spawn[@]}" + timeout 120 mcp-warden pin --lock "/out/$lock_rel" -- "${wrap[@]}" "${spawn[@]}" test -s "$lock_path" if [ "${SIGN:-0}" = "1" ]; then echo "== [$coord] stage 3: sign on host (v2 statement, ambient OIDC)" diff --git a/tools/plan.py b/tools/plan.py index 7074b4e..bf157ef 100755 --- a/tools/plan.py +++ b/tools/plan.py @@ -34,6 +34,7 @@ def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--attester", default="dse-nightly") ap.add_argument("--all", action="store_true", help="ignore existing locks (dry runs)") + ap.add_argument("--limit", type=int, default=0, help="attest at most N targets (0 = all)") ns = ap.parse_args() doc = yaml.safe_load((ROOT / "targets.yaml").read_text(encoding="utf-8")) out = [] @@ -49,6 +50,8 @@ def main() -> int: "env": t.get("env", {}), "lock_rel": str((d / f"{ns.attester}.lock").relative_to(ROOT)), }) + if ns.limit > 0: + out = out[: ns.limit] json.dump(out, sys.stdout, separators=(",", ":")) return 0