diff --git a/CLAUDE.md b/CLAUDE.md index fd743899f..a62b52ac4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,6 +146,7 @@ tail -f ~/Library/Logs/mcpproxy/main.log # main log (macOS; Linux: ~/.mcpproxy/ - **Windows installer**: [docs/github-actions-windows-wix-research.md](docs/github-actions-windows-wix-research.md). **Prerelease** (`next` branch + `v*-rc.*` tags, opt-in, off stable channels): [docs/prerelease-builds.md](docs/prerelease-builds.md). ## Recent Changes +- 101-tpa-db US3 + 087-tpa-daily-refresh (tpa-bundle-refresh): Go 1.26, Vue 3 Web UI — a refresh cycle (`internal/security/scanner/tpa_refresh.go`: file leg then optional fetch leg, jittered `tpa_refresh_interval` default 24h, single-flight joins, `Nudge` on `config.reloaded`, joined in `Runtime.Close`) feeds the same source-neutral `evaluateCandidate` pipeline; the opt-in fetch (`tpa_fetch_enabled` default off, `tpa_fetch_url` no compiled-in default, env `MCPPROXY_TPA_FETCH_ENABLED`; Releases-API `latest` or direct `` + `.sig`, signed-only via `evalEnv.MustSign`, https/loopback-only transport with dial-time egress checks, host-only logs, outcomes kept apart from `last_rejection`) works today only with operator-signed bundles (publisher key set still empty); the activation self-check (`tpa_selfcheck.go`, parity-tested port of the `cmd/scan-eval` gate, embedded `bundled/selfcheck-corpus.json`) refuses a candidate as `gate_failure`; fetched bundles survive reload/restart (`externalConfigured`) and disabling fetch keeps them serving; activation observers drive the cached-only re-protection sweep (`internal/server/scan_bundle_reprotect.go`, held-evidence leg + `Service.StartScanFromCachedContracts` gated by `auto_baseline_scan`, marker `storage.BundleReprotectState`, RT-02: never approves, unquarantines or revokes); `BundleInfo` gains `last_checked_at`, `last_activated_at`, `stale`, `refresh`, `reprotect`; surfaces `POST /api/v1/security/bundle/refresh`, `mcpproxy security bundle refresh`, `BundleDoctorWarnings`, telemetry schema 16 refresh counters. Docs: `docs/features/tpa-bundle-signing.md` "Daily refresh and optional fetch", `docs/features/security-quarantine.md` "Re-protection after a bundle activates". **No new dependencies.** - 101-tpa-db US1 (tpa-db-format): Go 1.26, Vue 3 Web UI — external TPA bundles are signed artifacts: `.sig` four-key Ed25519 sidecar (`internal/security/bundlesig`, pure, contract `specs/101-tpa-db/contracts/sidecar-format.md`), verify-before-parse source-neutral `Candidate` pipeline (`scanner/tpa_candidate.go`, FR-008 order, ceilings 8 MiB / 2000 rules / 4 KiB / 5 s via `candidate_open_{unix,windows}.go` no-follow descriptor opens), per-authority (publisher/operator) epoch-scoped watermark + ratchet + pin/deny-list in BBolt `tpa_bundle_state` (one tx with the history row) and content-addressed LKG files `/tpa-lkg/`, full-digest rollback, `coverageOK` + async scan-mode admission degrade on the four FR-007/009a/009b conditions. Config: `require_signed_bundle` (default off), `tpa_active_root`, `tpa_operator_signers`, `tpa_operator_baseline`, `tpa_operator_epoch`. Surfaces: `GET/POST /api/v1/security/bundle{,/history,/rollback,/unpin,/reload}`, `mcpproxy security bundle …`, `cmd/tpa-db keygen|sign|verify`, telemetry schema 15 bundle keys. Publisher key set ships EMPTY. Docs: `docs/features/tpa-bundle-signing.md`. **No new dependencies.** - 118-auto-rescan-on-change: Go 1.26, small Web UI change — after a tool definition changes (or a tool is added after the baseline) in `manual`/`scan` trust mode the in-process TPA scan of its current definition is recorded as hold evidence (`held_reason: awaiting_review` + `held_verdict: clean` for a clean verdict; findings/coverage as before) and a debounced informational server rescan (`scan_informational.go`, fed by `Runtime.AddToolDefinitionsChangedObserver`) brings the review scan line back to "current". Switch: `security.auto_rescan_on_change` (default on, env `MCPPROXY_AUTO_RESCAN_ON_CHANGE`) AND `auto_baseline_scan`. RT-02 untouched: a clean verdict never approves a change. Docs: `docs/features/security-quarantine.md` "Automatic informational baseline scan". **No new dependencies.** - quarantine-approval-pilot: Go 1.26, tests + CI only — independent policy model `internal/qapilot` (no production imports), deterministic scenario inventory S01–S26 + 896-cell dispatch table + REST authz table + counting-upstream dispatch check, check ledger (`QAPILOT_REPORT_DIR`) gated by `go run ./cmd/qapilot report`, versioned mutation set `tests/qapilot/mutations.json` run by `qapilot mutate` in disposable worktrees; workflow `.github/workflows/quarantine-pilot.yml` (3 PR checks + scheduled rapid exploration). Docs: `docs/development/quarantine-approval-pilot.md`. **No new dependencies.** diff --git a/ROADMAP.md b/ROADMAP.md index bc60f6a8c..e6f11e365 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -223,7 +223,7 @@ graph LR
🔵 tpa-db: versioned TPA signature database for the offline scanner — In progress · P1 -> Vision pillar 'feel protected': the deterministic detect engine (Spec 076/077) ships with built-in checks but no updatable knowledge of in-the-wild Tool Poisoning Attacks. Build a versioned, offline-first signature/pattern database (known TPA campaigns, malicious phrase corpora, IoC hashes) that the engine consumes — bundled with the binary, refreshable out-of-band, community-contributable, and guarded by the existing scan-eval recall/FP CI gate. SPEC STAGE: specs/101-tpa-db MERGED as PR #1028 on 2026-08-27 (kept in this note, not in pr:, because pr: is implementation evidence and a docs(specs) merge is not that); 2026-10-20: US1 (tpa-db-format: signed four-key Ed25519 sidecar, verify-before-parse loader, per-authority anti-downgrade state, full-digest rollback, coverage degrade) shipped as #1701; US2 corpus in a sibling session; US3 refresh waits on Spec 087. +> Vision pillar 'feel protected': the deterministic detect engine (Spec 076/077) ships with built-in checks but no updatable knowledge of in-the-wild Tool Poisoning Attacks. Build a versioned, offline-first signature/pattern database (known TPA campaigns, malicious phrase corpora, IoC hashes) that the engine consumes — bundled with the binary, refreshable out-of-band, community-contributable, and guarded by the existing scan-eval recall/FP CI gate. SPEC STAGE: specs/101-tpa-db MERGED as PR #1028 on 2026-08-27 (kept in this note, not in pr:, because pr: is implementation evidence and a docs(specs) merge is not that); 2026-10-20: US1 (tpa-db-format: signed four-key Ed25519 sidecar, verify-before-parse loader, per-authority anti-downgrade state, full-digest rollback, coverage degrade) shipped as #1701; US2 corpus in a sibling session (#1702, open); 2026-10-22: US3 (tpa-db-refresh, implements Spec 087 US1+US2: daily file+optional signed fetch refresh, activation self-check, freshness surfaces, cached re-protection sweep) shipped as #1720. Epic stays in_progress until the corpus (US2) lands. Spec: [101-tpa-db](./specs/101-tpa-db/) @@ -238,15 +238,15 @@ graph LR classDef done fill:#1f7a1f,stroke:#0d3d0d,color:#ffffff; classDef todo fill:#6e7781,stroke:#3d4248,color:#ffffff; - class tpa_db_format done; - class tpa_db_corpus,tpa_db_refresh todo; + class tpa_db_format,tpa_db_refresh done; + class tpa_db_corpus todo; ``` | Task | Status | Refs | | --- | --- | --- | | Signature DB format + loader (versioned, signed, bundled default) | 🟢 Done | #1701 | | Seed corpus: catalog known public TPA campaigns/patterns into the DB | ⚪ Todo | — | -| Out-of-band refresh (offline-friendly: manual file drop + optional fetch), eval-gated | ⚪ Todo | — | +| Out-of-band refresh (offline-friendly: manual file drop + optional fetch), eval-gated | 🟢 Done | #1720 |
@@ -1008,7 +1008,7 @@ graph LR | Web UI + macOS app UX audit | In progress | P0 | — | | | | Release qualification gate (auto-QA matrix blocks the tag) | In progress | P0 | 4/4 (100%) | [081-release-qa-gate](./specs/081-release-qa-gate/) | | | Action log / transparency — info at a glance | In progress | P1 | — | | | -| tpa-db: versioned TPA signature database for the offline scanner | In progress | P1 | 30/30 (100%) | [101-tpa-db](./specs/101-tpa-db/) | | +| tpa-db: versioned TPA signature database for the offline scanner | In progress | P1 | 81/81 (100%) | [101-tpa-db](./specs/101-tpa-db/) | | | Token-efficiency benchmark: measured savings, published results | In progress | P1 | 62/64 (97%) | [103-token-bench](./specs/103-token-bench/) | | | Telemetry identity & data quality (machine_id + CI-filter hardening) | In progress | P1 | — | | | | Telemetry v7: honest funnel + churn instrumentation | In progress | P1 | — | [080-telemetry-v7-churn](./specs/080-telemetry-v7-churn/) | | @@ -1158,7 +1158,7 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—` | [098-tools-preflight](./specs/098-tools-preflight/) | `in-flight` | 28/33 (85%) | | [099-describe-check-mode](./specs/099-describe-check-mode/) | `in-flight` | 9/10 (90%) | | [100-prompt-rugpull-baseline](./specs/100-prompt-rugpull-baseline/) | — | — | -| [101-tpa-db](./specs/101-tpa-db/) | `shipped` | 30/30 (100%) | +| [101-tpa-db](./specs/101-tpa-db/) | `shipped` | 81/81 (100%) | | [102-schema-deferred](./specs/102-schema-deferred/) | `shipped` | 89/89 (100%) | | [103-token-bench](./specs/103-token-bench/) | `shipped` | 62/64 (97%) | | [104-auto-routing-mode](./specs/104-auto-routing-mode/) | — | — | diff --git a/cmd/mcpproxy/doctor_cmd.go b/cmd/mcpproxy/doctor_cmd.go index 9e27cf7b3..05ca2fc8a 100644 --- a/cmd/mcpproxy/doctor_cmd.go +++ b/cmd/mcpproxy/doctor_cmd.go @@ -591,6 +591,16 @@ func outputDiagnosticsWithProfileChecks(diag map[string]interface{}, info map[st fmt.Println("⚠️ Runtime Warnings") fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") for _, warningItem := range runtimeWarnings { + // The daemon's management Doctor emits free-text warnings as + // plain strings (config findings, audit counters, the + // signature-bundle freshness warnings); the legacy path emits + // structured objects. Both render here. + if warningText, ok := warningItem.(string); ok { + if warningText != "" { + fmt.Printf(" • %s\n", warningText) + } + continue + } if warningMap, ok := warningItem.(map[string]interface{}); ok { message := getStringField(warningMap, "message") severity := getStringField(warningMap, "severity") diff --git a/cmd/mcpproxy/doctor_cmd_test.go b/cmd/mcpproxy/doctor_cmd_test.go index a23d81322..919984835 100644 --- a/cmd/mcpproxy/doctor_cmd_test.go +++ b/cmd/mcpproxy/doctor_cmd_test.go @@ -628,6 +628,46 @@ func TestOutputDiagnostics_WarningWithoutTitle(t *testing.T) { } } +// The daemon's management Doctor emits runtime_warnings as plain strings +// (appData denial, config findings, audit counters and, from Spec 101 US3, the +// signature-bundle freshness warnings registered via AddRuntimeWarningSource). +// A string warning must render under "Runtime Warnings" and must suppress the +// all-clear verdict; before this it was counted in total_issues but printed +// nowhere. +func TestOutputDiagnostics_StringRuntimeWarningRendersAndSuppressesAllClear(t *testing.T) { + const warning = "TPA signature bundle is 81 days old (stale after 30): run `mcpproxy security bundle refresh`" + diag := map[string]interface{}{ + "total_issues": 1, + "runtime_warnings": []interface{}{warning}, + } + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + defer func() { os.Stdout = oldStdout }() + + doctorOutput = "pretty" + err := outputDiagnostics(diag, nil, nil, "", nil) + + w.Close() + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + if err != nil { + t.Errorf("outputDiagnostics() returned error: %v", err) + } + if !strings.Contains(output, "Runtime Warnings") { + t.Error("Missing runtime warnings section header for a string warning") + } + if !strings.Contains(output, warning) { + t.Errorf("A plain-string runtime warning must be printed, got:\n%s", output) + } + if strings.Contains(output, "All systems operational") { + t.Error("A runtime warning must suppress the all-clear verdict") + } +} + func TestOutputDiagnostics_HighSeverityWarning(t *testing.T) { diag := map[string]interface{}{ "total_issues": 1, diff --git a/cmd/mcpproxy/security_bundle_cmd_test.go b/cmd/mcpproxy/security_bundle_cmd_test.go index ab6394e95..91b55808c 100644 --- a/cmd/mcpproxy/security_bundle_cmd_test.go +++ b/cmd/mcpproxy/security_bundle_cmd_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -280,3 +281,192 @@ func TestSignatureBundleLines_UnsignedExternalShowsNoAuthorityNoise(t *testing.T assert.Contains(t, joined, "DEGRADED: unsigned_external_active") assert.Contains(t, joined, "unsigned", "an unsigned external bundle says so") } + +// --- Spec 087 / Spec 101 US3: `security bundle refresh` and the freshness lines --- + +// bundleCLIRefreshedBody is a fetch-sourced bundle that is stale and whose last +// refresh cycle was rate limited, with a re-protection sweep that found a hit. +func bundleCLIRefreshedBody() string { + return `{ + "source": "fetch", + "runnable_rules": 10, + "skipped_rules": 0, + "declared_skipped": 0, + "generated_at": "2026-08-01T00:00:00Z", + "fingerprint": "0123456789ab", + "full_digest": "` + bundleCLIDigest + `", + "sequence": "42", + "authority": "operator", + "trust_epoch": 1, + "signature_verified": true, + "staleness_days": 81, + "stale": true, + "stale_after_days": 30, + "coverage_degraded": false, + "loaded_at": "2026-10-20T00:00:00Z", + "last_checked_at": "2026-10-21T06:00:00Z", + "last_activated_at": "2026-10-20T06:00:00Z", + "refresh": { + "fetch_enabled": true, + "fetch_host": "mirror.example.test", + "interval_seconds": 86400, + "last_trigger": "manual", + "last_outcome": "fetch_unavailable", + "last_reason": "rate_limited", + "last_attempt_at": "2026-10-21T06:00:05Z", + "consecutive_failures": 2, + "next_check_at": "2026-10-22T06:00:00Z" + }, + "reprotect": { + "state": "idle", + "last_completed_at": "2026-10-20T06:05:00Z", + "servers_scanned": 4, + "servers_with_hits": 1 + } +}` +} + +func TestSecurityBundleRefresh_PostsAndPrintsFreshnessLines(t *testing.T) { + rec := newRESTRecorder(t, map[string]cannedResponse{"POST /api/v1/security/bundle/refresh": okResp(bundleCLIRefreshedBody())}) + + out, _, err := runCLI(t, GetSecurityCommand, "table", "bundle", "refresh") + require.NoError(t, err) + + req := rec.only(t) + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, "/api/v1/security/bundle/refresh", req.Path) + + assert.Contains(t, out, "Last checked:") + assert.Contains(t, out, "2026-10-21 06:00:00") + assert.Contains(t, out, "Last activated:") + assert.Contains(t, out, "2026-10-20 06:00:00") + assert.Contains(t, out, "Refresh:") + assert.Contains(t, out, "fetch_unavailable (rate_limited)") + assert.Contains(t, out, "next check 2026-10-22 06:00:00") + assert.Contains(t, out, "Fetch host:") + assert.Contains(t, out, "mirror.example.test") + assert.Contains(t, out, "Re-protection:") + assert.Contains(t, out, "idle") + assert.Contains(t, out, "STALE", "a stale bundle gets a loud line, not just an age number") + assert.Contains(t, out, "30 days", "the stale line names the threshold") +} + +func TestSecurityBundleRefresh_JSONAndYAMLPassTheFieldsThrough(t *testing.T) { + newRESTRecorder(t, map[string]cannedResponse{"POST /api/v1/security/bundle/refresh": okResp(bundleCLIRefreshedBody())}) + + out, _, err := runCLI(t, GetSecurityCommand, "json", "bundle", "refresh") + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &got), out) + assert.Equal(t, true, got["stale"]) + assert.Equal(t, "2026-10-21T06:00:00Z", got["last_checked_at"]) + refresh, _ := got["refresh"].(map[string]any) + require.NotNil(t, refresh) + assert.Equal(t, "fetch_unavailable", refresh["last_outcome"]) + reprotect, _ := got["reprotect"].(map[string]any) + require.NotNil(t, reprotect) + assert.Equal(t, "idle", reprotect["state"]) + + out, _, err = runCLI(t, GetSecurityCommand, "yaml", "bundle", "refresh") + require.NoError(t, err) + assert.Contains(t, out, "last_outcome: fetch_unavailable") +} + +func TestSecurityBundleRefresh_AgentTokenRefusalIsSurfaced(t *testing.T) { + newRESTRecorder(t, map[string]cannedResponse{"POST /api/v1/security/bundle/refresh": refuse(http.StatusForbidden, `{"success":false,"error":"operation requires admin access"}`)}) + _, _, err := runCLI(t, GetSecurityCommand, "table", "bundle", "refresh") + require.Error(t, err) + assert.Contains(t, err.Error(), "requires admin access") +} + +// The freshness lines also appear on the plain status read (one edit in +// signatureBundleLines covers status, overview and every mutation). +func TestSecurityBundleStatus_ShowsFreshnessLines(t *testing.T) { + newRESTRecorder(t, map[string]cannedResponse{"GET /api/v1/security/bundle": okResp(bundleCLIRefreshedBody())}) + out, _, err := runCLI(t, GetSecurityCommand, "table", "bundle") + require.NoError(t, err) + for _, want := range []string{"Last checked:", "Last activated:", "Refresh:", "Fetch host:", "Re-protection:", "STALE"} { + assert.Contains(t, out, want) + } +} + +func TestSignatureBundleLines_RefreshAndReprotectDetails(t *testing.T) { + var bundle map[string]any + require.NoError(t, json.Unmarshal([]byte(bundleCLIRefreshedBody()), &bundle)) + joined := strings.Join(signatureBundleLines(map[string]any{"signature_bundle": bundle}), "\n") + + assert.Contains(t, joined, "1 of 4 servers", "the sweep line says how many scanned servers matched the new corpus") + assert.Contains(t, joined, "2 consecutive failures", "repeated failures are called out") +} + +func TestSignatureBundleLines_ReprotectSuspendedShowsReason(t *testing.T) { + joined := strings.Join(signatureBundleLines(map[string]any{"signature_bundle": map[string]any{ + "source": "file", "path": "/p", "runnable_rules": float64(6), + "reprotect": map[string]any{ + "state": "suspended", "reason": "auto_baseline_scan_off", + "servers_scanned": float64(0), "servers_with_hits": float64(0), + }, + }}), "\n") + assert.Contains(t, joined, "Re-protection:") + assert.Contains(t, joined, "suspended") + assert.Contains(t, joined, "auto_baseline_scan_off") +} + +func TestSignatureBundleLines_RefreshDisabledShowsReasonNotHost(t *testing.T) { + joined := strings.Join(signatureBundleLines(map[string]any{"signature_bundle": map[string]any{ + "source": "file", "path": "/p", "runnable_rules": float64(6), + "refresh": map[string]any{ + "fetch_enabled": true, "interval_seconds": float64(86400), + "last_outcome": "disabled", "last_reason": "no_trusted_signer", + }, + }}), "\n") + assert.Contains(t, joined, "Refresh:") + assert.Contains(t, joined, "disabled (no_trusted_signer)") + assert.NotContains(t, joined, "Fetch host:", "no host is known, so no host line") + assert.NotContains(t, joined, "next check", "no next_check_at, so no next-check clause") +} + +func TestSignatureBundleLines_EmbeddedWithoutRefreshHasNoFreshnessNoise(t *testing.T) { + joined := strings.Join(signatureBundleLines(map[string]any{"signature_bundle": map[string]any{ + "source": "embedded", "runnable_rules": float64(6), "coverage_degraded": false, + "sequence": "5", "authority": "publisher", "staleness_days": float64(12), + }}), "\n") + assert.NotContains(t, joined, "STALE") + assert.NotContains(t, joined, "Fetch host") + assert.NotContains(t, joined, "Last checked") + assert.NotContains(t, joined, "Last activated") + assert.NotContains(t, joined, "Refresh:") + assert.NotContains(t, joined, "Re-protection") +} + +func TestSignatureBundleLines_FetchOffRefreshBlockHasNoHostLine(t *testing.T) { + joined := strings.Join(signatureBundleLines(map[string]any{"signature_bundle": map[string]any{ + "source": "file", "path": "/p", "runnable_rules": float64(6), + "last_checked_at": "2026-10-21T06:00:00Z", + "refresh": map[string]any{ + "fetch_enabled": false, "interval_seconds": float64(86400), + "last_trigger": "timer", "last_outcome": "unchanged", + "next_check_at": "2026-10-22T06:00:00Z", + }, + }}), "\n") + assert.Contains(t, joined, "Last checked:") + assert.Contains(t, joined, "unchanged") + assert.NotContains(t, joined, "Fetch host") + assert.NotContains(t, joined, "STALE") +} + +// The daemon stamps the re-protection completion in UTC while the cycle +// timestamps carry the host's local offset, so the human line must convert it +// to local time like the rest of the block. +func TestBundleReprotectText_LastCompletedShownInLocalTime(t *testing.T) { + orig := time.Local + time.Local = time.FixedZone("TEST", 5*3600+30*60) + t.Cleanup(func() { time.Local = orig }) + + text := bundleReprotectText(map[string]any{"reprotect": map[string]any{ + "state": "idle", + "last_completed_at": "2026-10-20T06:05:00Z", + }}) + assert.Contains(t, text, "last completed 2026-10-20 11:35:00") + assert.NotContains(t, text, "06:05:00", "the UTC clock must not leak into the human output") +} diff --git a/cmd/mcpproxy/security_cmd.go b/cmd/mcpproxy/security_cmd.go index 6ffd191aa..3df1992b9 100644 --- a/cmd/mcpproxy/security_cmd.go +++ b/cmd/mcpproxy/security_cmd.go @@ -2311,6 +2311,7 @@ func signatureBundleLines(overview map[string]interface{}) []string { } lines = append(lines, field("Generated", generated)) } + lines = append(lines, bundleFreshnessLines(bundle, field)...) if fingerprint, _ := bundle["fingerprint"].(string); fingerprint != "" { lines = append(lines, field("Fingerprint", fingerprint)) } @@ -2354,6 +2355,14 @@ func signatureBundleLines(overview map[string]interface{}) []string { } lines = append(lines, field("Coverage", "DEGRADED: "+reason+" (scan-mode auto-approval is suspended)")) } + if stale, _ := bundle["stale"].(bool); stale { + // An age number alone reads as trivia; a stale corpus is a missing- + // signature risk, so it gets its own loud line (Spec 087 FR-017). + lines = append(lines, field("STALE", bundleStaleText(bundle))) + } + if reprotect := bundleReprotectText(bundle); reprotect != "" { + lines = append(lines, field("Re-protection", reprotect)) + } if pinned, _ := bundle["pinned"].(bool); pinned { lines = append(lines, field("Pinned", "yes - pinned by an operator rollback (`security bundle unpin` clears it)")) } @@ -2385,6 +2394,104 @@ func signatureBundleLines(overview map[string]interface{}) []string { return append(lines, "") } +// bundleFreshnessLines renders when the corpus was last checked and activated +// and how the last refresh cycle went (Spec 087 FR-017, tasks B15). Everything +// is optional: an older daemon, or an embedded default that never refreshed, +// yields no lines at all. +func bundleFreshnessLines(bundle map[string]interface{}, field func(label, value string) string) []string { + var lines []string + if checked, _ := bundle["last_checked_at"].(string); checked != "" { + lines = append(lines, field("Last checked", formatTimestamp(checked))) + } + if activated, _ := bundle["last_activated_at"].(string); activated != "" { + lines = append(lines, field("Last activated", formatTimestamp(activated))) + } + refresh, ok := bundle["refresh"].(map[string]interface{}) + if !ok || len(refresh) == 0 { + return lines + } + outcome, _ := refresh["last_outcome"].(string) + text := "no cycle has run yet" + if outcome != "" { + text = outcome + if reason, _ := refresh["last_reason"].(string); reason != "" { + text += " (" + reason + ")" + } + } + if failures, _ := refresh["consecutive_failures"].(float64); failures > 0 { + noun := "failures" + if int(failures) == 1 { + noun = "failure" + } + text += fmt.Sprintf(", %d consecutive %s", int(failures), noun) + } + if next, _ := refresh["next_check_at"].(string); next != "" { + text += ", next check " + formatTimestamp(next) + } + lines = append(lines, field("Refresh", text)) + // The host is shown only while fetch is on and a host is known. The URL is + // never reported by the daemon (host-only logging, tasks B12). + if enabled, _ := refresh["fetch_enabled"].(bool); enabled { + if host, _ := refresh["fetch_host"].(string); host != "" { + lines = append(lines, field("Fetch host", host)) + } + } + return lines +} + +// bundleStaleText is the STALE line: age, threshold and the way out. +func bundleStaleText(bundle map[string]interface{}) string { + age := "older than the threshold" + if days, ok := bundle["staleness_days"].(float64); ok { + age = fmt.Sprintf("%d days old", int(days)) + } + threshold := "" + if after, ok := bundle["stale_after_days"].(float64); ok { + threshold = fmt.Sprintf(" (threshold %d days)", int(after)) + } + return age + threshold + " - run `mcpproxy security bundle refresh`, or check that the refresh source is reachable" +} + +// bundleReprotectText describes the post-activation re-protection sweep (Spec 101 +// FR-019): its state, why it is suspended, and how many scanned servers matched +// the newly active corpus. Empty when the daemon reports no sweep status. +func bundleReprotectText(bundle map[string]interface{}) string { + rp, ok := bundle["reprotect"].(map[string]interface{}) + if !ok || len(rp) == 0 { + return "" + } + state, _ := rp["state"].(string) + if state == "" { + return "" + } + text := state + if reason, _ := rp["reason"].(string); reason != "" { + text += " (" + reason + ")" + } + scanned, _ := rp["servers_scanned"].(float64) + hits, _ := rp["servers_with_hits"].(float64) + if scanned > 0 { + text += fmt.Sprintf(", %d of %d servers matched the active corpus", int(hits), int(scanned)) + } + if done, _ := rp["last_completed_at"].(string); done != "" { + text += ", last completed " + formatLocalTimestamp(done) + } + return text +} + +// formatLocalTimestamp is formatTimestamp rendered in the host's local zone. +// The daemon stamps the re-protection completion in UTC while the cycle +// timestamps carry the host's local offset, so the human line converts it to +// keep every time in the block on the same clock. +func formatLocalTimestamp(ts string) string { + for _, layout := range []string{time.RFC3339, time.RFC3339Nano} { + if t, err := time.Parse(layout, ts); err == nil { + return t.Local().Format("2006-01-02 15:04:05") + } + } + return ts +} + func secFormatInt(m map[string]interface{}, key string) string { if v, ok := m[key].(float64); ok { return fmt.Sprintf("%d", int(v)) @@ -2392,7 +2499,7 @@ func secFormatInt(m map[string]interface{}, key string) string { return "0" } -// --- security bundle (Spec 101 US1: status, history, rollback, unpin, reload) --- +// --- security bundle (Spec 101 US1: status, history, rollback, unpin, reload; US3: refresh) --- // bundleFullDigestPattern is the only digest grammar rollback accepts: the // complete SHA-256 as 64 lowercase hex characters. The daemon enforces the same @@ -2408,8 +2515,11 @@ and verified, or stuck on last-known-good and why. The status shows the full SHA-256 digest (the value "rollback" requires), the sequence, the signing authority and trust epoch, whether the signature -verified, staleness, a DEGRADED coverage line with its reason when scan-mode -auto-approval is suspended, the rollback pin, and the last refused candidate. +verified, staleness (with a STALE line past the configured threshold), when the +bundle was last checked and activated, how the last refresh cycle went, the +post-activation re-protection sweep, a DEGRADED coverage line with its reason +when scan-mode auto-approval is suspended, the rollback pin, and the last +refused candidate. Examples: mcpproxy security bundle @@ -2418,7 +2528,8 @@ Examples: mcpproxy security bundle rollback --embedded --digest <64-hex> mcpproxy security bundle rollback --path /var/lib/tpa/known-good.json --digest <64-hex> mcpproxy security bundle unpin - mcpproxy security bundle reload`, + mcpproxy security bundle reload + mcpproxy security bundle refresh`, Args: cobra.NoArgs, RunE: runSecurityBundleStatus, } @@ -2426,6 +2537,7 @@ Examples: cmd.AddCommand(newSecurityBundleRollbackCmd()) cmd.AddCommand(newSecurityBundleUnpinCmd()) cmd.AddCommand(newSecurityBundleReloadCmd()) + cmd.AddCommand(newSecurityBundleRefreshCmd()) return cmd } @@ -2504,7 +2616,8 @@ func newSecurityBundleReloadCmd() *cobra.Command { Short: "Re-run the bundle pipeline against the configured source", Long: `Re-read the configured bundle source and run it through the same verification pipeline as startup. A refused candidate leaves last-known-good -serving and is reported as the last rejection. +serving and is reported as the last rejection. Reload never makes a network +request; use "refresh" to also check the fetch channel. Examples: mcpproxy security bundle reload`, @@ -2515,6 +2628,33 @@ Examples: } } +func newSecurityBundleRefreshCmd() *cobra.Command { + return &cobra.Command{ + Use: "refresh", + Short: "Check for a newer bundle now (local file, then the optional fetch)", + Long: `Run one refresh cycle immediately: re-read the configured bundle file, then, +when security.tpa_fetch_enabled is on and a trusted signer and security.tpa_fetch_url +are configured, check the fetch channel for a newer signed bundle. Everything +goes through the same verification pipeline as startup; a refused candidate +leaves last-known-good serving and is reported as the last rejection. + +The status shows what the cycle did on the "Refresh" line: activated, unchanged, +not_modified, rejected, fetch_unavailable (a network or rate-limit problem, never +a rejection), channel_invalid, superseded or disabled (with the reason). + +"refresh" may make one outbound request when fetch is enabled; "reload" never +does - it only re-reads the local file. + +Examples: + mcpproxy security bundle refresh + mcpproxy security bundle refresh -o json`, + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return runSecurityBundleMutation("/api/v1/security/bundle/refresh", "refresh bundle", "Bundle refresh finished. Now serving:") + }, + } +} + // validateBundleRollbackFlags enforces the rollback grammar locally, so a // malformed request is refused before any request is made. func validateBundleRollbackFlags(embedded bool, path string, pathChanged bool, digest string) error { diff --git a/docs/cli/security-commands.md b/docs/cli/security-commands.md index 52241a2a7..3cf035214 100644 --- a/docs/cli/security-commands.md +++ b/docs/cli/security-commands.md @@ -688,7 +688,7 @@ mcpproxy security overview -o json \ ## security bundle -Show and manage the offline TPA signature corpus (Spec 101): which bundle is live, whether its signature verified, whether coverage is degraded, and the last candidate that was refused. Full operator guide: [Signed TPA Bundles](/features/tpa-bundle-signing). +Show and manage the offline TPA signature corpus (Spec 101, Spec 087): which bundle is live, whether its signature verified, whether coverage is degraded, how fresh it is, what the last refresh did, and the last candidate that was refused. Full operator guide: [Signed TPA Bundles](/features/tpa-bundle-signing). ### Usage @@ -697,10 +697,46 @@ mcpproxy security bundle [-o json|yaml] # status mcpproxy security bundle history # every activation, oldest first mcpproxy security bundle rollback (--embedded | --path P) --digest <64-hex> mcpproxy security bundle unpin # clear the rollback pin -mcpproxy security bundle reload # re-run the load pipeline now +mcpproxy security bundle reload # re-read the local file now (never the network) +mcpproxy security bundle refresh [-o json|yaml] # run one refresh cycle now (file, then the optional fetch) ``` -The status prints the full digest in full, because `rollback` accepts only the complete 64-character SHA-256 (the 12-character fingerprint is refused). When coverage is degraded it prints `DEGRADED: `, and while it is degraded `scan`-mode auto-approval is suspended. `rollback`, `unpin` and `reload` need the admin API key and are refused for agent tokens. +The status prints the full digest in full, because `rollback` accepts only the complete 64-character SHA-256 (the 12-character fingerprint is refused). When coverage is degraded it prints `DEGRADED: `, and while it is degraded `scan`-mode auto-approval is suspended. `rollback`, `unpin`, `reload` and `refresh` need the admin API key and are refused for agent tokens. + +### Freshness lines in the status + +| Line | Meaning | +|---|---| +| `Last checked` | The end of the most recent refresh or reload run, including runs that found nothing new. | +| `Last activated` | When the serving external bundle was activated (absent for the embedded corpus). | +| `Refresh` | What the last cycle did, for example `unchanged, next check 2026-10-22 08:14` or `fetch_unavailable (network_error), 3 consecutive failures`. See the outcome list in [Signed TPA Bundles](/features/tpa-bundle-signing#outcomes-versus-rejections). | +| `Fetch host` | Shown only while the fetch is enabled: the host of `security.tpa_fetch_url`. The URL itself is never printed. | +| `STALE` | The bundle is older than `security.tpa_stale_after_days` (default 30), with the way out. Absent when the threshold is `0` or the bundle is fresh. | +| `Re-protection` | State of the sweep that re-checks approved tools after a bundle activates, for example `idle, 1 of 3 servers matched the active corpus`. See [Re-protection after a bundle activates](/features/security-quarantine#re-protection-after-a-bundle-activates). | + +`-o json` and `-o yaml` pass the same fields through (`last_checked_at`, `last_activated_at`, `stale`, `stale_after_days`, `refresh`, `reprotect`). + +### `refresh` versus `reload` + +`refresh` runs one cycle immediately: it re-reads `security.tpa_bundle_path` and, only when `security.tpa_fetch_enabled` is on and the fetch is allowed to run (a URL, a trusted signer for the active root), checks the channel for a newer signed bundle. It joins a cycle that is already running instead of starting a second one, and it returns when the cycle is done (the CLI waits up to 30 seconds). With the fetch off, which is the default, it makes no network request and behaves like `reload` plus the status of the cycle. + +`reload` only re-reads the local file. It never makes a network request, even when the fetch is on. + +```bash +$ mcpproxy security bundle refresh +Bundle refresh finished. Now serving: + Signature bundle: + Source: fetch (https://mirror.example.com) + Generated: 2026-10-20T09:00:00Z (1 days old) + Last checked: 2026-10-21 08:14:02 + Last activated: 2026-10-21 08:14:02 + Refresh: activated, next check 2026-10-22 08:09 + Fetch host: mirror.example.com + ... + Re-protection: running +``` + +A refused candidate leaves the previous bundle serving and appears as `Last rejection`. A network problem appears on the `Refresh` line as `fetch_unavailable` and is not a rejection. The REST endpoint is `POST /api/v1/security/bundle/refresh`. --- diff --git a/docs/configuration.md b/docs/configuration.md index 7165dca33..80d63da92 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -936,6 +936,10 @@ block** — off by default, best-effort, and unable to change the baseline verdi "tpa_operator_signers": [], "tpa_operator_baseline": 0, "tpa_operator_epoch": 0, + "tpa_refresh_interval": "24h", + "tpa_fetch_enabled": false, + "tpa_fetch_url": "", + "tpa_stale_after_days": 30, "auto_baseline_scan": true, "auto_rescan_on_change": true, "deep_scan": { @@ -956,6 +960,10 @@ block** — off by default, best-effort, and unable to change the baseline verdi | `tpa_operator_signers` | string[] | `[]` | Operator Ed25519 public keys, each the standard base64 of the 32 raw key bytes. Rejected on write if an entry is malformed, not canonically encoded, or a duplicate. | | `tpa_operator_baseline` | integer | absent | The anti-downgrade floor for operator bundles: a bundle must carry a sequence strictly above it. Raise it when you retire older releases for good. Absent means no floor. An explicit `0` is kept and is different from absent. | | `tpa_operator_epoch` | integer | absent (`0`) | The operator trust epoch. Raise it after an operator key compromise to retire pins from earlier epochs. | +| `tpa_refresh_interval` | duration | absent (`24h`) | How often the refresh cycle runs. Each cycle first re-reads `tpa_bundle_path` (network-free, always on, idempotent) and then, when `tpa_fetch_enabled` is on, runs the fetch step. There is no key that turns the cycle itself off. Absent or `0` means `24h`; anything under `1h` is rejected on write and clamped to `1h` if it is already on disk. Each run is jittered by about 10%, and a suspended laptop produces one cycle on wake, not a burst. Hot-reloadable. | +| `tpa_fetch_enabled` | boolean | `false` | Opt in to downloading a signed bundle and its `.sig` from `tpa_fetch_url`. Off by default, and a fetched bundle is held to the same verification as a file drop (signature, sequence watermark, deny-list, activation self-check). Env override: `MCPPROXY_TPA_FETCH_ENABLED` (`true`/`1`/`false`/`0`), which wins over this field on every path but is never written back to the config file. The environment can switch the fetch on or off, but it cannot choose the URL or the trusted keys. Setting `MCPPROXY_DISABLE_AUTO_UPDATE` does **not** switch this fetch off; they are separate. | +| `tpa_fetch_url` | string | `""` | Where the fetch step looks. **There is no working default URL**: empty means the fetch makes no request (`disabled`, reason `no_url`), and no URL is compiled into the binary until a release ships the first publisher key. Two shapes are recognised from the URL path: a GitHub Releases `…/repos///releases/latest` API URL (the release must carry one `scanner-bundle-.json` asset with a sibling `.sig`), or any other path, fetched as the bundle with `.sig` as its signature. Must be `https` (plain `http` only for `localhost` or a loopback address) and must not contain credentials, a query string or a fragment; the URL is never logged beyond its host. Rejected on write when `tpa_fetch_enabled` is on and the URL is empty. | +| `tpa_stale_after_days` | integer | absent (`30`) | Age in days of the active bundle (from its signed `generated_at`) after which it is flagged stale in `GET /api/v1/security/bundle`, `mcpproxy security bundle status` and `mcpproxy doctor`. An explicit `0` disables the stale flag and the doctor warning, and is different from absent. Negative values are rejected on write. | | `auto_baseline_scan` | boolean | `true` | Kill switch for the **automatic informational baseline scan**. When on (the default), every newly added server gets one free in-process Pass-1 TPA scan, and once per installation a background sweep scans pre-existing enabled servers that have never been scanned (marker persisted in BBolt, so it runs exactly once and never delays startup). The result only populates the security badge / scan summary: it **never** quarantines, approves, or otherwise gates a server. Disabled servers are skipped, and so are `trust_mode: "scan"` servers — that mode's own admission gate scans them and auto-approves on a clean verdict, so the informational path stays out of it entirely rather than risk feeding that gate; this flag does not affect that separate path. Set to `false` to suppress all automatic scans; manual scans keep working. Hot-reloadable — the flag is read live at each decision point. Env override: `MCPPROXY_AUTO_BASELINE_SCAN` (`true`/`1`/`false`/`0`), which wins over this field on every path. | | `auto_rescan_on_change` | boolean | `true` | Finer switch for the **automatic rescan after a tool definition changes** (Spec 118), in every trust mode except `auto`. When on, a changed tool, a tool added after the baseline, or a changed definition that moves again gets the in-process TPA scan of its current definition recorded as hold evidence (`held_reason`/`held_verdict`/`held_signals`, so the review screen shows a per-tool verdict without a click), and a debounced (about 5 s, coalesced per server) informational rescan of the server brings the review screen's scan line back to "current". Evidence and freshness only: a clean verdict **never** approves a changed or new tool, and no approval rule changes. It only takes effect while `auto_baseline_scan` is also on, so set this to `false` to keep the admission scan and turn off per-change rescans. The `trust_mode: "scan"` gate's own admission-time scan of new tools is not affected. Hot-reloadable. Env override: `MCPPROXY_AUTO_RESCAN_ON_CHANGE` (`true`/`1`/`false`/`0`), which wins over this field on every path. | | `deep_scan.enabled` | boolean | `false` | Master opt-in for the heavy layer. When `false`, no Docker scanner runs and no source extraction is attempted — only the in-process baseline scanner executes. | @@ -963,7 +971,9 @@ block** — off by default, best-effort, and unable to change the baseline verdi | `deep_scan.disable_no_new_privileges` | boolean | `false` | Omits `--security-opt no-new-privileges` from scanner container runs (snap-docker/AppArmor escape hatch). | | `deep_scan.scanners` | string[] | `[]` | Optional allow-list of deep scanner ids. Empty ⇒ all enabled deep scanners are eligible. | -For the operator workflow around these keys (signing, dropping, rolling back, key compromise) see [Signed TPA Bundles](/features/tpa-bundle-signing). These five keys have no environment-variable aliases on purpose: the trust root lives only in `mcp_config.json`, so someone who can change the bundle path or the environment cannot also choose who is trusted. `GET /api/v1/config` shows them unmasked, since they hold public keys and counters, not secrets. +For the operator workflow around these keys (signing, dropping, rolling back, key compromise) see [Signed TPA Bundles](/features/tpa-bundle-signing). The trust keys (`require_signed_bundle`, `tpa_active_root`, `tpa_operator_signers`, `tpa_operator_baseline`, `tpa_operator_epoch`) have no environment-variable aliases on purpose: the trust root lives only in `mcp_config.json`, so someone who can change the bundle path or the environment cannot also choose who is trusted. The same holds for the fetch URL, interval and stale threshold; only `tpa_fetch_enabled` has an env switch. `GET /api/v1/config` shows all of these keys unmasked, since they hold public keys, counters, durations and a plain URL, not secrets. + +**Fetching needs an operator root today.** A fetched bundle is verified against the active root's trust set, and the built-in publisher key set ships empty. With the default `tpa_active_root: "publisher"`, turning the fetch on therefore makes no request and reports `disabled` with reason `no_trusted_signer`. To use the fetch before a publisher key is released, set `tpa_active_root` to `"operator"` and list your signing keys in `tpa_operator_signers` (add `tpa_operator_baseline` for non-degraded coverage). Turning the fetch off later keeps the last verified fetched bundle serving; `mcpproxy security bundle rollback --embedded` is the explicit way back to the embedded corpus, and a rollback to an earlier fetched release needs that file in the `tpa_bundle_path` directory. The shape checks above run only when a config is written (REST, CLI, Web UI); a bad value already on disk never stops the daemon from starting and is treated as "fetch off". **Deprecated-key migration.** The old top-level `security.scanner_fetch_package_source` and `security.scanner_disable_no_new_privileges` keys still parse and are migrated diff --git a/docs/features/security-quarantine.md b/docs/features/security-quarantine.md index 6f8c74ac3..82179422a 100644 --- a/docs/features/security-quarantine.md +++ b/docs/features/security-quarantine.md @@ -686,6 +686,16 @@ fingerprint (`mcpproxy security bundle rollback`). The operator lifecycle, every rejection and degraded reason, key compromise and the threat-model boundary are in [Signed TPA Bundles](/features/tpa-bundle-signing). +**Daily refresh and optional fetch (Spec 087).** The configured bundle file is +re-read about every 24 hours (`security.tpa_refresh_interval`), so a dropped file +activates without a restart. A fetch of a signed bundle from a URL you choose +(`tpa_fetch_enabled`, `tpa_fetch_url`) is available but **off by default, with no +default URL**, and today works only with operator-signed bundles because the +publisher key set is still empty. A fetched bundle passes the same checks as a +file. `mcpproxy security bundle refresh` runs one cycle now; `reload` never +touches the network. After a new bundle activates, approved tools are re-checked +against it: see [Re-protection after a bundle activates](#re-protection-after-a-bundle-activates). + The "Add time" column applies to every admission path — the `upstream_servers` tool, the REST API, a registry add, and (since issue #937) a first-seen server in `mcp_config.json`. See @@ -697,6 +707,76 @@ add-server form chooses the mode instead of a raw quarantine checkbox (initial quarantine is derived from the mode); server tiles show a trust-mode badge. +### Re-protection after a bundle activates {#re-protection-after-a-bundle-activates} + +A tool you approved last month was checked against the corpus that existed then. +When a newer signature bundle activates (a refresh, a fetch, a dropped file or a +rollback), that tool may now match a signature that did not exist when you +approved it. MCPProxy re-checks your approved tools against the new corpus +without waiting for them to change, and **only ever adds evidence for a human**. + +The sweep runs once per activation (a given bundle digest is swept once; a +rollback is a new activation and one more sweep). It does not run for a +last-known-good restore at startup or for a refused candidate. If the last +completed sweep covered a different bundle than the one now active (an +activation before the app was fully up, a crash mid-sweep, an upgrade that +changed the embedded corpus), a startup catch-up runs it once. It has two legs: + +- **Held-tool evidence.** For every server with `pending` or `changed` tools, + quarantined servers included, the scan of the stored current definition is + repeated under the new corpus and the evidence shown on the tool is updated + (see [Hold evidence in the Web UI](#hold-evidence-in-the-web-ui-spec-088)). A + signature that the newer corpus no longer has leaves the tool held, with a clean + verdict that still waits for your review. +- **Cached scan of the stored tools.** For every non-quarantined server with at + least one approved tool, in any trust mode, an informational scan runs over the + stored current definition of every tool record, approved and held alike, so the + result describes the same tools a live scan does and never replaces a verdict + that rests on a held tool with a clean one. The report, scan summary, server-list + badge, Security overview and activity log show the result, and the tool's scan + verdict on the server's review view (`GET /api/v1/servers/{name}/review`) + reflects a finding, naming the matching signature as `tpa..`. + +**What it never does (RT-02).** It never approves, blocks, revokes, disables or +unquarantines anything, and a clean verdict approves nothing. An approved tool +stays approved with the same approved hash, a held tool stays held, a quarantined +server stays quarantined. The cached scan skips quarantined servers on purpose (checked again at the moment +each server's scan starts), and the settle event of a re-protection job is +ignored by the scan-mode auto-approval and the review capture (both belong to +quarantined servers). + +**Cached only.** The sweep reads what MCPProxy already stored. It does not +connect to the upstream server, does not call `tools/list`, starts no Docker deep +scan, is not counted in the scan telemetry, and cannot be influenced by a slow or +hostile server. A server with no stored approved contracts is reported as +unscanned, never as clean. + +**Pacing.** Servers are swept one at a time, with a short pause between them. A +server that is busy with another scan is retried without counting as a failure; a +server whose scan cannot start is tried at most three times. One sweep produces at +most one finding per tool and one activity record per server, so a large install +does not get an alert storm. + +**It depends on `security.auto_baseline_scan`.** The cached scan is an +informational scan, so it follows the same switch (env +`MCPPROXY_AUTO_BASELINE_SCAN`). With it off, the held-tool evidence still +refreshes, the cached scan does not run, and the status says so +(`reprotect.state: suspended`, `reason: auto_baseline_scan_off`). Turning the +switch back on resumes the suspended sweep without a restart. + +**Where to see it.** `mcpproxy security bundle` prints a `Re-protection:` line +(state, how many scanned servers matched the active corpus, when it last +completed), and `GET /api/v1/security/bundle` and `/api/v1/security/overview` +carry the same `reprotect` object (`idle`, `running`, `suspended` or `failed`). +There is no new attention or review-queue item for "an approved tool now matches a +signature": a server with only approved tools does not enter `/review` or the Home +attention list. The finding is visible on the surfaces above. Whether to add an +attention kind for it is an open decision, since it touches every surface of the +Web UI and the macOS app. + +**Limitation.** In stdio mode there is no scanner service, so the cached scan of +approved tools does not run; the held-tool evidence refresh still does. + ### Hold evidence in the Web UI (spec 088) When the `scan` gate holds a tool, the approval record carries evidence — diff --git a/docs/features/telemetry.md b/docs/features/telemetry.md index 246ca36d3..10e3339dd 100644 --- a/docs/features/telemetry.md +++ b/docs/features/telemetry.md @@ -10,7 +10,7 @@ MCPProxy collects anonymous usage statistics to help improve the product. This p ## What is collected -MCPProxy sends a **daily heartbeat** containing only aggregate, non-identifying information. The current schema is **version 14** (`schema_version: 14` in the JSON payload); the schema is forward-compatible so older consumers simply ignore fields they don't recognize. +MCPProxy sends a **daily heartbeat** containing only aggregate, non-identifying information. The current schema is **version 16** (`schema_version: 16` in the JSON payload); the schema is forward-compatible so older consumers simply ignore fields they don't recognize. | Field | Example | Purpose | |-------|---------|---------| @@ -40,7 +40,7 @@ MCPProxy sends a **daily heartbeat** containing only aggregate, non-identifying | `previous_shutdown` | `clean` | How the previous process instance ended — fixed enum `clean` / `crash`, absent on first run (schema v7) | | `last_error_code` | `MCPX_DOCKER_CLI_NOT_FOUND` | Most recent stable `MCPX_*` diagnostic code (schema v7). Enum code only, never error text | | `diagnostics.current_error_codes` | `{"MCPX_OAUTH_LOGIN_REQUIRED":2}` | How many configured servers are in each failure state **right now** (schema v11) — fixed `MCPX_*` enum keys, counts only. Never server names. Omitted when nothing is failing. See below | -| `tpa_scanner` | `{"scans_completed":4,"scans_failed":0,"scans_with_findings":1,"findings":{"high":2},"tool_change_gate_scans":6,"prompt_scans":11}` | Security/TPA scanner activity (schema v8, extended in v9) — counts only, keyed by the fixed severity enum. Omitted entirely when no scan of any kind ran | +| `tpa_scanner` | `{"scans_completed":4,"scans_failed":0,"scans_with_findings":1,"findings":{"high":2},"tool_change_gate_scans":6,"prompt_scans":11}` | Security/TPA scanner activity (schema v8, extended in v9; v15 adds the active signature bundle's provenance, v16 the refresh counters) — counts and closed enums only, keyed by the fixed severity enum. Omitted entirely when no scan ran, no refresh cycle ran and no bundle provider is wired | | `trust_mode_distribution` | `{"auto":1,"scan":3,"manual":8}` | Configured servers per effective trust tier (schema v9) — fixed enum keys `auto`/`scan`/`manual`, counts only. Never server names | | `feature_flags.deep_scan_enabled` | `false` | Whether the opt-in deep-scan layer is turned on (schema v8) | | `feature_flags.server_edition_enabled` | `false` | Whether the `server_edition` block is present and enabled (schema v13, Spec 107). Always `false` on the personal edition, where the block is never interpreted | @@ -232,6 +232,39 @@ The two new counters share the window and reset semantics of every other registr The anonymity scanner enforces both shapes on the wire form: the v9 counters widen the `tpa_scanner` key whitelist (rule `v8_field_invalid`), and `trust_mode_distribution` gets its own rule `trust_mode_field_invalid` — fixed trust-tier keys with non-negative integer counts, or the heartbeat is blocked. +## Schema v15 and v16 — the TPA signature bundle (Specs 087 and 101) + +The scanner's detection rules ship as a signed **bundle**. Schema v15 reports which bundle is active; schema v16 counts how often it was refreshed. Both live in the `tpa_scanner` object and keep the closed-contract posture: booleans, non-negative integers and fixed enums only. No age, timestamp, URL, host, reason string or digest is ever sent. + +### v15 — the active bundle (state, not a counter) + +These four keys describe the bundle in force **at the moment the heartbeat is built**. They are recomputed on every heartbeat and are never cleared by the reset-on-accept window. They are validated as one tuple by the anonymity scanner: if any `bundle_*` key is present, `bundle_source`, `bundle_signature_verified` and `bundle_version` must all be present. + +| Field | Type | When it is set | Privacy rationale | +|-------|------|----------------|-------------------| +| `tpa_scanner.bundle_source` | enum `embedded` / `file` / `fetch` | Where the active bundle came from: the copy built into the binary, a file at `security.tpa_bundle_path`, or a bundle fetched from the refresh channel (`fetch` is reported from v16 on) | Three-value enum. The path and the URL are never sent | +| `tpa_scanner.bundle_signature_verified` | boolean | `true` when the bundle's signature verified against a trusted key; an explicit `false` is transmitted | A boolean about our own state | +| `tpa_scanner.bundle_version` | the literal `other`, or a corpus schema line such as `0.1` | A schema line only for the embedded default or a bundle verified against a **publisher** key; anything else (an unsigned file or fetch, or a bundle verified by an operator key) reports `other` | The schema line (`^[0-9]{1,3}\.[0-9]{1,3}$`) is a normalisation. The near-unique release string never leaves the install, and an operator corpus that copies a publisher version string cannot masquerade as that release | +| `tpa_scanner.bundle_sequence` | non-negative integer, at most 2^53-1 | Present only together with a schema-line `bundle_version` | The publisher's monotonic release counter; absent for `other` | + +Because the bundle keys are state, `tpa_scanner` is also emitted when every scan counter is zero, whenever the bundle provider returns a snapshot. + +### v16 — refresh counters + +Five reset-on-accept counters count the refresh lifecycle (the daily check of the bundle file and the optional fetch). Each is a non-negative integer, **omitted while zero**, and zeroed only after an accepted (2xx) heartbeat like every other registry counter. + +| Field | Type | When it is set | Privacy rationale | +|-------|------|----------------|-------------------| +| `tpa_scanner.bundle_refresh_cycles` | non-negative integer | One per refresh cycle, whatever its outcome | An invocation count of our own scheduler | +| `tpa_scanner.bundle_refresh_activations` | non-negative integer | Cycles that activated a bundle | Never which bundle, from where or at which sequence | +| `tpa_scanner.bundle_refresh_rejections` | non-negative integer | Cycles in which the pipeline refused a candidate (bad signature, downgrade, failed self-check and the like) | Never the reason; the refusal reason stays in the local status | +| `tpa_scanner.bundle_refresh_unavailable` | non-negative integer | Cycles in which the fetch could not reach the channel, was rate limited, or got a malformed channel answer | Never the URL, the host or the HTTP status | +| `tpa_scanner.bundle_reprotect_hits` | non-negative integer | Servers whose post-activation re-protection scan of already-approved tools had at least one hard finding | A count of servers only; no server name, tool name or rule id | + +**Not a partition.** A cycle that found nothing new (unchanged, not modified, superseded, or fetch disabled) counts only in `bundle_refresh_cycles`, so the three class counters do not sum to it. Do not add them to the v8 `scans_*` counters either: the unit is a refresh cycle (or a server), not a scan job. + +**What is deliberately absent.** The bundle's age, `generated_at`, the last-checked and last-activated times, the next-check time, the fetch URL and host, refusal reasons and digests are never transmitted. The freshness view ("how old is the bundle in the fleet") is computed on the receiver by joining `bundle_sequence` against the release timeline, not reported by the client. The anonymity scanner rejects any `tpa_scanner` key outside the whitelist, and the five counters above must be non-negative integers (strings, negatives and floats block the heartbeat). With telemetry disabled (`MCPPROXY_TELEMETRY=false`, `DO_NOT_TRACK`, the CLI or config opt-out) none of these keys, and no `tpa_scanner` object, is sent. + ## Preflight baseline counters (issue #969) The `preflight` sub-object measures two things the proxy currently does silently: diff --git a/docs/features/tool-scanner.md b/docs/features/tool-scanner.md index 38df2368e..0d48610cc 100644 --- a/docs/features/tool-scanner.md +++ b/docs/features/tool-scanner.md @@ -310,8 +310,50 @@ degraded, `scan`-mode auto-approval is suspended** and changes are held for human review (held reason `scan_coverage`). The detect engine itself is unaffected and keeps scanning. -See [Signed TPA Bundles](/features/tpa-bundle-signing) for signing, rollback and -the full list of reasons. +A configured corpus is also re-read about once a day (`tpa_refresh_interval`), +and an opt-in, default-off fetch can download a signed one from a URL you choose. +Both feed the same checks as a file drop. + +### Activation self-check {#activation-self-check} + +A bundle can be well formed, correctly signed and newer, and still be a bad +corpus: a signature that matches every tool description would flag benign tools, +and a corpus that lost its attack signatures would stop detecting known attacks. +Before a new corpus replaces the serving one, MCPProxy scores it on a labeled +corpus compiled into the binary, with the same decision rule as +[the eval gate](#the-eval-gate-ci-enforced-reliability): + +- malicious **recall** over the gated categories must be at least **0.90**, and +- the **false-positive rate** over the hard-negative set must be at most **0.05**. + +The scoring runs the four hard-tier built-in checks plus the candidate corpus. +A candidate that fails is refused as `gate_failure`, the previous corpus keeps +serving, and `last_rejection` says how many hard negatives it flagged or how +many attacks it missed, naming corpus ids only (never the candidate's patterns). + +- It applies to every source: a dropped file, a fetched bundle, and a rollback. + An operator-signed bundle is **not** exempt, because a valid signature proves + who published a corpus, not that it detects well. +- It does not run when MCPProxy restores a last-known-good bundle at startup (it + already passed), and the corpus embedded in the binary is not checked. +- It fails closed. A scoring corpus that cannot be read, has no gated malicious + entry or no hard negative (so the rates would mean nothing), or a check that + crashed while scoring, refuses the candidate rather than passing it. +- It runs once per distinct candidate. A pair that was refused is remembered and + is not re-scored on the next cycle. +- Until the scoring corpus carries signature-category (`tpa_*`) attack samples + (Spec 101 US2), the recall leg measures only the built-in checks, which gives + every candidate the same recall. The false-positive leg is the one that tells + candidates apart, so a corpus that is too broad is caught and one that is too + narrow is not yet. + +The scoring code is a copy of the gate's rule inside the server, because the CI +tool is a separate program that cannot be imported. Parity tests compare the two +and the embedded scoring corpus against the dataset in the repository, so they +cannot drift apart unnoticed. + +See [Signed TPA Bundles](/features/tpa-bundle-signing) for signing, rollback, +the refresh cycle and the full list of reasons. ## Related reading diff --git a/docs/features/tpa-bundle-signing.md b/docs/features/tpa-bundle-signing.md index 594b072cf..065e911d1 100644 --- a/docs/features/tpa-bundle-signing.md +++ b/docs/features/tpa-bundle-signing.md @@ -2,15 +2,15 @@ id: tpa-bundle-signing title: Signed TPA Bundles (Spec 101) sidebar_label: Signed TPA Bundles -description: Sign, drop, verify, roll back and monitor an offline TPA signature corpus, with downgrade protection that works without a network. -keywords: [security, tpa, signature bundle, ed25519, anti-downgrade, rollback, air-gapped, tpa-db, scanner] +description: Sign, drop, verify, roll back and monitor an offline TPA signature corpus, with downgrade protection that works without a network, a daily refresh, and an opt-in signed fetch. +keywords: [security, tpa, signature bundle, ed25519, anti-downgrade, rollback, air-gapped, tpa-db, scanner, refresh, fetch, re-protection] --- # Signed TPA Bundles (Spec 101) The `scan` trust mode matches tool descriptions against an offline corpus of Tool Poisoning Attack (TPA) signatures, the tpa-db `scanner-bundle.json`. MCPProxy ships one inside the binary. This page is for operators who want to run a different corpus from disk and want the proxy to prove that the corpus is the one they signed, and that nobody has swapped in an older one. -Everything on this page works with no network. Dropping a file is the primary path, not a fallback. Fetching bundles over the network is a later feature (see [What is not in this release](#what-is-not-in-this-release)). +Everything on this page works with no network. Dropping a file is the primary path, not a fallback. A daily refresh re-reads that file for you, and an **opt-in, default-off** fetch can also download a signed bundle from a channel you name (see [Daily refresh and optional fetch](#daily-refresh-and-optional-fetch)). Nothing is fetched unless you turn it on and give it a URL, and there is no default URL. For the short version of how the corpus fits into quarantine, see [Signature bundle (offline TPA corpus)](/features/security-quarantine#signature-bundle-offline-tpa-corpus). For the detection engine the corpus feeds, see [Tool Scanner](/features/tool-scanner#signature-corpus-and-coverage). @@ -20,8 +20,9 @@ For the short version of how the corpus fits into quarantine, see [Signature bun 2. Put the public key in `security.tpa_operator_signers`, declare `tpa_active_root: "operator"`, and set a baseline and an epoch. 3. Author a bundle whose manifest carries `sequence`, `trust_epoch`, `key_id` and `generated_at`, then sign it with `tpa-db sign`. 4. Drop `scanner-bundle.json` and `scanner-bundle.json.sig` side by side at `tpa_bundle_path`. -5. Activate it (config reload, restart, or `mcpproxy security bundle reload`) and read the status. -6. If a release turns out to be bad, roll back by full digest. If a key leaks, bump `tpa_operator_epoch`. +5. Activate it (config reload, restart, `mcpproxy security bundle reload`, or just wait for the next daily refresh cycle) and read the status. +6. Optionally let MCPProxy fetch later releases itself (`tpa_fetch_enabled`, `tpa_fetch_url`). +7. If a release turns out to be bad, roll back by full digest. If a key leaks, bump `tpa_operator_epoch`. ## What is signed, and why @@ -41,7 +42,7 @@ You never write it by hand; `tpa-db sign` produces it. The full grammar is in `s ## Configuration keys -These live under `security` in `mcp_config.json`. They have no environment-variable aliases on purpose: the trust root is config-resident, so someone who can change the environment or the bundle file cannot also choose who is trusted. `GET /api/v1/config` shows them unmasked because they hold public keys and counters, not secrets. +These live under `security` in `mcp_config.json`. The trust keys have no environment-variable aliases on purpose: the trust root is config-resident, so someone who can change the environment or the bundle file cannot also choose who is trusted. The only environment switch among the keys is `MCPPROXY_TPA_FETCH_ENABLED` (plus the existing `MCPPROXY_TPA_BUNDLE_PATH`); it can turn the fetch on or off, but it cannot choose the URL or the keys, and it is never written back to the config file. `GET /api/v1/config` shows them unmasked because they hold public keys and counters, not secrets. | Key | Default | Meaning | |---|---|---| @@ -51,6 +52,10 @@ These live under `security` in `mcp_config.json`. They have no environment-varia | `tpa_operator_signers` | `[]` | Operator public keys, each the standard base64 of the 32 raw key bytes. | | `tpa_operator_baseline` | absent | The anti-downgrade floor for operator bundles. A bundle must have a sequence strictly above it. Absent means no floor and degraded coverage. An explicit `0` is different from absent. | | `tpa_operator_epoch` | absent (`0`) | The operator trust epoch. Bundles must carry exactly this `trust_epoch`. | +| `tpa_refresh_interval` | absent (`24h`) | How often the refresh cycle runs. At least `1h`. See [Daily refresh and optional fetch](#daily-refresh-and-optional-fetch). | +| `tpa_fetch_enabled` | `false` | Opt in to downloading a signed bundle from `tpa_fetch_url`. Env override `MCPPROXY_TPA_FETCH_ENABLED`. | +| `tpa_fetch_url` | `""` | The channel to fetch from. Empty means no request is ever made. There is no compiled-in default. | +| `tpa_stale_after_days` | absent (`30`) | Age in days after which the active bundle is flagged stale. `0` disables the flag. | The field-by-field reference is in the Security section of `docs/configuration.md` in the repository. Invalid values are rejected when you write the config (`PATCH /api/v1/config`, `POST /api/v1/config/apply`). A malformed or duplicate signer found in a file that was edited by hand is dropped with a log line instead of stopping the daemon, so a bad key can never brick startup and never widens what is trusted. @@ -115,12 +120,133 @@ Put `scanner-bundle.json` and `scanner-bundle.json.sig` at `tpa_bundle_path`. Wr - the configuration is reloaded (any hot-reload of `mcp_config.json`), - the daemon restarts, -- you run `mcpproxy security bundle reload`, or call `POST /api/v1/security/bundle/reload`. +- the next [refresh cycle](#daily-refresh-and-optional-fetch) runs (within `tpa_refresh_interval`, 24 hours by default), with no restart, +- you run `mcpproxy security bundle reload`, or call `POST /api/v1/security/bundle/reload`, +- you run `mcpproxy security bundle refresh` (the same file re-read, plus the fetch when it is on). -The reload command runs the same single-flight pipeline the other two use, and returns the updated status. Reloading a file that is already active is a no-op: no log line, no history row. +All of them run the same single-flight pipeline and return the updated status. Reloading a file that is already active is a no-op: no log line, no history row. Every candidate goes through the same checks in the same order, and the first failure wins. Whatever fails, the previously active corpus keeps serving. A rejection never leaves the scanner with an empty or partial rule set. +## Daily refresh and optional fetch + +Spec 087 adds a refresh cycle on top of the file drop. It has two legs, run in this order every cycle: + +1. **File leg.** Re-reads `tpa_bundle_path` and runs it through the pipeline. Network-free, always on, idempotent: an unchanged file costs nothing and writes no history. There is no key that turns the cycle off. This is what makes an air-gapped drop activate by itself within a day. +2. **Fetch leg.** Only when [all of its prerequisites hold](#when-the-fetch-may-make-a-request), downloads a signed pair from the channel and offers it to the same pipeline. + +The first cycle runs 30 seconds to 5 minutes after startup. Later cycles run every `tpa_refresh_interval` (default `24h`, minimum `1h`) with about 10% jitter, so a fleet restarted together does not hit the channel in lockstep. The timer is re-armed after each cycle, so a suspended laptop produces one cycle when it wakes, not a burst. A manual `refresh` and a timer tick that arrive together join the cycle already running, so there is one download. A `reload` or a config reload runs through the same serialized pipeline and is idempotent, so a given bundle is activated once however many triggers see it. + +### The fetch is opt-in, has no default URL, and needs an operator root today + +- **Default off.** `tpa_fetch_enabled` is `false`. Until you set it (or `MCPPROXY_TPA_FETCH_ENABLED=true`), the fetch leg makes zero requests. +- **No default URL.** `tpa_fetch_url` is empty and no URL is compiled into the binary. An empty URL means no request, reported as `disabled` with reason `no_url`. The URL shapes below are examples of what you would configure, not something MCPProxy contacts on its own. +- **Works today only with operator-signed bundles.** The publisher key set embedded in the binary is still empty, so a publisher-signed bundle is refused as `key_not_trusted`. A fetched bundle is verified against the same trust set as a dropped one, so the fetch is only useful with `tpa_active_root: "operator"` and your own key in `tpa_operator_signers`. With the publisher root and the empty publisher set, the fetch leg stays silent (reason `no_trusted_signer`) and `mcpproxy doctor` says so. +- **`MCPPROXY_DISABLE_AUTO_UPDATE` does not apply.** It governs the MCPProxy release check. The bundle fetch has its own switch. + +```json +{ + "security": { + "tpa_active_root": "operator", + "tpa_operator_signers": [""], + "tpa_operator_baseline": 1, + "tpa_fetch_enabled": true, + "tpa_fetch_url": "https://api.github.com/repos///releases/latest", + "tpa_refresh_interval": "24h" + } +} +``` + +Writing the config (`PATCH /api/v1/config`, `POST /api/v1/config/apply`) validates the shape: the URL must be `https` (plain `http` only for `localhost` or a loopback address), must have no credentials, query string or fragment, and must not be empty while the fetch is on; fetch with `tpa_active_root: "operator"` and no signers is refused; the interval must be at least `1h`. The error messages never repeat the URL. A malformed value already on disk is logged and treated as "fetch off" instead of stopping the daemon. + +### When the fetch may make a request + +The fetch leg sends a request only when all of these hold. Otherwise it reports `disabled` and a reason, and nothing leaves the machine: + +| Reason | Meaning | +|---|---| +| `fetch_off` | `tpa_fetch_enabled` is false. | +| `no_url` | `tpa_fetch_url` is empty. | +| `invalid_url` | The URL does not pass the shape rules above. | +| `no_trusted_signer` | The active root has no keys to verify against (the publisher set is empty, or the operator root has no `tpa_operator_signers`). | +| `state_unavailable` | The anti-downgrade state store could not be loaded, so nothing could be activated safely. | + +### Channel shapes + +The shape is chosen from the **path** of `tpa_fetch_url` alone. + +- **Releases API, "latest".** A path of the form `/repos///releases/latest` (for example the GitHub API URL above). MCPProxy reads the release JSON (capped at 1 MiB) and needs exactly one asset named `scanner-bundle-.json` with a sibling `scanner-bundle-.json.sig`. Zero or several such assets, or a missing `.sig`, is `channel_invalid`. Both assets are downloaded through their `browser_download_url`. The sequence in the file name is an unsigned hint, used only to skip a download whose sequence is already at or below the watermark; it is never trusted for activation. +- **Direct.** Any other path. The URL is the bundle and `.sig` is its sidecar. A `404` on the sidecar is treated as "unsigned", which a fetch always refuses as `signature_required`, even when `require_signed_bundle` is off. Any other sidecar failure is `fetch_unavailable` with reason `http_status`. + +Both shapes send `If-None-Match` with the `ETag` of the previous answer (kept in memory, so it resets at restart), so an unchanged channel answers `304` (`not_modified`) with no download. The bundle and sidecar are held to the same size ceilings as a dropped pair (8 MiB, 4096 bytes). The file names match the file-drop convention (`` plus `.sig`), so a pair downloaded by hand from the same channel can be dropped at `tpa_bundle_path` as it is. + +### Transport rules + +- https is required, except for `localhost` and loopback literals (a local mirror or a test). +- A request has a 20 second total budget and a 10 second response-header timeout. +- Redirects: at most 5 hops, never from https to http. A redirect may cross hosts, because GitHub release assets are served from another host and integrity comes from the signature, not from the host. +- At connect time, link-local, cloud-metadata (169.254.0.0/16 and fe80::/10), unspecified and multicast addresses are always refused. Loopback and private ranges are refused unless the host you configured is itself in that range (a mirror on your LAN), so a public URL cannot redirect the fetch inward. With `HTTP_PROXY` or `HTTPS_PROXY` set, the same check is applied to the target host before the request. +- **Logs and status carry the host only.** The URL, its path and any redirect targets never appear in logs, in `GET /api/v1/security/bundle`, in `last_rejection` or in error text. `refresh.fetch_host` is the only place a name shows up. + +### What a fetched bundle has to pass + +Exactly what a dropped bundle passes, in the same order: Ed25519 signature over the raw bytes, key id, epoch and sequence agreement, the per-authority watermark and the equal-sequence-different-digest check, the deny-list, then the [activation self-check](/features/tool-scanner#activation-self-check). A substituted or replayed asset therefore fails as `tamper` or `downgrade`. A withheld asset (an attacker who just blocks new releases) cannot be detected from a single response, so it surfaces as staleness: see the `stale` field and the doctor warning below. + +A fetched bundle is stored like any activated external bundle: its last-known-good copy is kept in the data directory and restored at the next start, and `source` reads `fetch`. It keeps serving across config reloads and restarts. **Turning the fetch off does not drop it.** Falling back silently to the embedded corpus would be a downgrade you did not ask for; use `mcpproxy security bundle rollback --embedded --digest ` to go back deliberately. + +### Outcomes versus rejections + +Two different things can go wrong, and the status keeps them apart. + +- A **rejection** means a candidate was refused by the pipeline (`tamper`, `downgrade`, `gate_failure`, `signature_required`, and the other reasons in the [table above](#rejection-reasons)). It is recorded in `last_rejection`, and the previous bundle keeps serving. +- A **non-verification outcome** means nothing was verified at all: the network failed, the server rate-limited you, or the listing was malformed. It is reported in `refresh`, never in `last_rejection`, and it never touches the watermark. + +`refresh.last_outcome` uses a closed vocabulary: + +| `last_outcome` | Meaning | +|---|---| +| `activated` | A new bundle became active this cycle. | +| `unchanged` | Nothing newer than what is serving (or a refused pair that was already refused once). | +| `not_modified` | The channel answered `304`. | +| `rejected` | A candidate was refused; see `last_rejection`. | +| `fetch_unavailable` | Network error, timeout, a non-2xx status or a rate limit. `last_reason` is `network_error`, `timeout`, `http_status` or `rate_limited`. A rate limit pushes the next attempt to the reset time (at most 24 hours). | +| `channel_invalid` | The Releases listing did not have exactly one usable asset pair. | +| `superseded` | The fetch settings changed while a download was in flight, so the result was discarded. | +| `disabled` | The fetch leg did not run; `last_reason` is one of the table above. | + +`refresh.last_trigger` is `startup`, `config` (a config reload), `timer` or `manual`. `consecutive_failures` counts `fetch_unavailable` and `channel_invalid` cycles in a row and resets on any other outcome. Persisting failures are logged once per change and then at most once a day. + +### `refresh` versus `reload` + +| Command | Reads the local file | Makes a network request | +|---|---|---| +| `mcpproxy security bundle reload` | yes | **never** | +| `mcpproxy security bundle refresh` | yes | at most one cycle, only if the fetch is on and allowed | + +`reload` is the offline tool and stays offline even when the fetch is on. `refresh` runs one cycle now, joins a cycle already in flight, and returns the status with the outcome in `refresh`. + +### Freshness, stale flag and doctor + +The status adds these fields (all omitted when unknown): + +| Field | Meaning | +|---|---| +| `last_checked_at` | The end of the most recent pipeline run, including runs that found nothing to do. | +| `last_activated_at` | When the serving external bundle was activated. Absent for the embedded corpus. | +| `stale`, `stale_after_days` | `true` when the bundle's `generated_at` is older than `tpa_stale_after_days` (default 30). Computed when you read the status, so editing the threshold takes effect immediately. A `generated_at` in the future is never stale. | +| `refresh` | `fetch_enabled`, `fetch_host`, `interval_seconds`, `last_trigger`, `last_outcome`, `last_reason`, `fetch_state`, `fetch_reason`, `last_attempt_at`, `consecutive_failures`, `next_check_at`. `fetch_state` and `fetch_reason` are the fetch leg's own last answer, kept apart from `last_outcome`: a dropped file that activates owns the cycle outcome, but a fetch that cannot run (for example `no_trusted_signer`) stays visible. When the file activates, the fetch leg waits for the next cycle. | +| `reprotect` | The state of the [re-protection sweep](/features/security-quarantine#re-protection-after-a-bundle-activates). | + +`mcpproxy doctor` adds at most three short warnings, none of which contains a digest, path, URL or host: the bundle is stale (only when you configured a way to refresh it), a newer candidate was refused or the refresh has failed three or more times in a row (so you are stuck on last-known-good), and the fetch is enabled but cannot run (`no_url`, `invalid_url` or `no_trusted_signer`). A stale embedded corpus on an install that never configured a refresh shows in the status only; updating MCPProxy is what refreshes it. + +### Rolling back when you only fetch + +A rollback by path needs the target file in the directory of `tpa_bundle_path`. On an install that fetches and has no `tpa_bundle_path`, only `rollback --embedded` is available. To keep the option of returning to an earlier fetched release, set `tpa_bundle_path` to a directory you control and keep the downloaded pairs of the releases you may want next to it. Rolling back deny-lists the release you rolled away from, so the channel offering it again is refused quietly (`deny_listed`) and does not reactivate it; a strictly newer signed release activates normally and clears the pin. + +### Air-gapped use of a downloaded pair + +You do not need the fetch to use the channel. Download `scanner-bundle-.json` and `scanner-bundle-.json.sig` on a connected machine, carry them across, and drop them as `tpa_bundle_path` and `.sig` (any one name pair works, since the names are yours to choose). The file leg picks them up on the next cycle or on `reload`, and they go through the same verification. + ## Reading the status ```bash @@ -132,8 +258,8 @@ The same object is returned by `GET /api/v1/security/bundle`. | Field | Meaning | |---|---| -| `source` | `embedded` or `file`. | -| `path` | The configured path, when `source` is `file`. | +| `source` | `embedded`, `file` or `fetch`. | +| `path` | The configured path, when `source` is `file`. For `fetch` it is the origin (`https://host`) only, never the full URL. | | `bundle_version`, `schema_version` | The corpus's own declared versions. | | `runnable_rules`, `skipped_rules`, `declared_skipped` | How many rules are live, and how many are not. Skipped rules are un-evaluated coverage, never clean coverage. | | `generated_at`, `staleness_days` | The corpus freshness stamp and its age in whole days. | @@ -163,7 +289,7 @@ The human table always prints the full digest in full, because you will paste it | `sidecar_invalid` | The sidecar breaks its grammar. `detail` carries the exact code (`sidecar_malformed`, `sidecar_missing_field`, `sidecar_unknown_field`, `sidecar_duplicate_field`, `sidecar_type`, `sidecar_version_unsupported`, `sidecar_algorithm_unsupported`, `sidecar_key_id_malformed`, `sidecar_signature_malformed`). A present but invalid sidecar is never treated as "no sidecar". | | `invalid_bundle` | The bundle did not parse, has a bad `sequence` or `trust_epoch`, an unsupported version, a pattern that does not compile, or no runnable rule. | | `deny_listed` | You rolled away from this exact release. It stays refused until you remove or replace the file. | -| `gate_failure` | The activation self-check refused the candidate. The self-check hook is a no-op in this release. | +| `gate_failure` | The [activation self-check](/features/tool-scanner#activation-self-check) refused the candidate: it would have missed too many known attacks or flagged too many benign tools. `detail` carries counts and corpus ids, never the candidate's patterns. | | `ceiling_exceeded` | Over a limit: bundle larger than 8 MiB, more than 2000 entries in `rules`, or sidecar larger than 4096 bytes. `detail` gives the limit and the observed value. A file exactly at a limit loads. | | `not_regular_file` | The path (or its sidecar) is a FIFO, device, socket, directory or a symlink. Point `tpa_bundle_path` at the real file. | | `read_timeout` | The read did not finish in 5 seconds. See [Platform notes](#platform-notes). | @@ -327,18 +453,21 @@ The bundle and sidecar are opened without following symlinks, and the checks are | `mcpproxy security bundle rollback (--embedded \| --path P) --digest D` | `POST /api/v1/security/bundle/rollback` | admin API key | | `mcpproxy security bundle unpin` | `POST /api/v1/security/bundle/unpin` | admin API key | | `mcpproxy security bundle reload` | `POST /api/v1/security/bundle/reload` | admin API key | +| `mcpproxy security bundle refresh` | `POST /api/v1/security/bundle/refresh` | admin API key | All commands accept `-o json` and `-o yaml`. The mutating endpoints refuse agent tokens. Scoped tokens cannot read the status either. ## What is not in this release -- **No publisher key has been issued.** The publisher signer set embedded in the binary is empty, so a bundle signed by the publisher is refused as `key_not_trusted`. The operator authority is the only signed path today. A later release will add the key, and with it the publisher baseline. -- **No network fetch.** Bundles are dropped by hand. Fetching, the daily refresh and a re-scan after activation belong to Spec 087. -- **No seed corpus yet.** The corpus that ships in the binary does not carry `sequence`, `trust_epoch` or `key_id`, which is why the walkthrough stamps them with `jq`. The structured `signatures[]` section, the contributor workflow and the detection-quality gate before signing are part of the next story of this spec. -- **The activation self-check is a stub.** The hook exists and maps a failure to `gate_failure`, but nothing refuses a candidate through it yet. +- **No publisher key has been issued.** The publisher signer set embedded in the binary is empty, so a bundle signed by the publisher is refused as `key_not_trusted`. The operator authority is the only signed path today, which is also why the fetch works only with operator-signed bundles. A later release will add the key, and with it the publisher baseline. +- **No default fetch URL.** The first official channel is not published yet, so nothing is compiled in. You choose where to fetch from. +- **No signed release index.** The fetch trusts only what the signature, watermark and deny-list establish. A channel that just withholds new releases is visible as staleness, not as an error. +- **No seed corpus yet.** The corpus that ships in the binary does not carry `sequence`, `trust_epoch` or `key_id`, which is why the walkthrough stamps them with `jq`. The structured `signatures[]` section and the contributor workflow are part of Spec 101 US2. +- **No "check now" button in the Web UI.** The Security tab shows the refresh rows; `mcpproxy security bundle refresh` and the REST endpoint run a cycle. ## See also - [Security Quarantine: Signature bundle](/features/security-quarantine#signature-bundle-offline-tpa-corpus) +- [Security Quarantine: Re-protection after a bundle activates](/features/security-quarantine#re-protection-after-a-bundle-activates) - [Tool Scanner: Signature corpus and coverage](/features/tool-scanner#signature-corpus-and-coverage) - Spec: `specs/101-tpa-db/spec.md`, walkthrough: `specs/101-tpa-db/quickstart.md` diff --git a/frontend/src/components/security/SignatureBundlePanelBody.vue b/frontend/src/components/security/SignatureBundlePanelBody.vue index 963dc0335..9e0d147f5 100644 --- a/frontend/src/components/security/SignatureBundlePanelBody.vue +++ b/frontend/src/components/security/SignatureBundlePanelBody.vue @@ -26,6 +26,14 @@ {{ digestCopied ? 'Copied' : 'Copy' }} +
{{ problem.explanation }}
Last refused candidate: {{ summary.lastRejection.label }} — {{ summary.lastRejection.detail }} diff --git a/frontend/src/utils/signatureBundle.ts b/frontend/src/utils/signatureBundle.ts index 74a3b172e..e6a2e9019 100644 --- a/frontend/src/utils/signatureBundle.ts +++ b/frontend/src/utils/signatureBundle.ts @@ -34,6 +34,38 @@ export interface SignatureBundle { pinned?: boolean rollback?: boolean last_rejection?: BundleRejection | null + // --- Spec 087 / Spec 101 US3 freshness fields (tasks B15) --- + last_checked_at?: string + last_activated_at?: string + stale?: boolean + stale_after_days?: number + refresh?: BundleRefresh | null + reprotect?: BundleReprotect | null +} + +/** Refresh-cycle status. Carries the fetch HOST only, never the URL. */ +export interface BundleRefresh { + fetch_enabled?: boolean + fetch_host?: string + interval_seconds?: number + last_trigger?: string + last_outcome?: string + last_reason?: string + /** The fetch leg's own last answer, apart from the merged cycle outcome. */ + fetch_state?: string + fetch_reason?: string + last_attempt_at?: string + consecutive_failures?: number + next_check_at?: string +} + +/** Post-activation re-protection sweep status (Spec 101 FR-019). */ +export interface BundleReprotect { + state?: string + reason?: string + last_completed_at?: string + servers_scanned?: number + servers_with_hits?: number } export interface BundleRejection { @@ -60,6 +92,15 @@ export interface LastRejectionSummary { at: string } +/** B16-style conditions: states of the refresh itself that need attention. */ +export type RefreshProblemKind = 'stale' | 'stuck' | 'fetch_inactive' + +export interface RefreshProblem { + kind: RefreshProblemKind + /** Plain-language why, carrying no digest, path, URL or host. */ + explanation: string +} + export interface SignatureBundleSummary { /** Stat title. */ title: string @@ -81,6 +122,14 @@ export interface SignatureBundleSummary { degraded: DegradedCoverage | null /** Most recent refused candidate; last-known-good is still serving. */ lastRejection: LastRejectionSummary | null + /** + * The first B16-style refresh condition (stale corpus, stuck on + * last-known-good, fetch configured but inactive), or null. Separate from + * `degraded`, which stays coverage-only. + */ + refreshProblem: RefreshProblem | null + /** Every refresh condition that holds, in priority order. */ + refreshProblems: RefreshProblem[] pinned: boolean rollback: boolean } @@ -138,6 +187,119 @@ function plural(n: number, unit: string): string { return `${n} ${unit}${n === 1 ? '' : 's'}` } +const OUTCOME_LABELS: Record = { + activated: 'Activated a new bundle', + // Neutral on purpose: `unchanged` also covers a candidate the pipeline already + // refused, and `not_modified` only says the remote resource did not change. + // Neither establishes that the serving corpus is current. + unchanged: 'No change', + not_modified: 'Remote not modified', + rejected: 'Candidate refused', + fetch_unavailable: 'Fetch unavailable', + channel_invalid: 'Channel invalid', + superseded: 'Superseded by a settings change', + disabled: 'Fetch disabled', +} + +const REASON_LABELS: Record = { + network_error: 'network error', + timeout: 'timed out', + http_status: 'unexpected HTTP status', + rate_limited: 'rate limited', + fetch_off: 'fetch is off', + no_url: 'no URL configured', + invalid_url: 'URL is invalid', + no_trusted_signer: 'no trusted signer configured', + state_unavailable: 'anti-downgrade state unavailable', +} + +const REPROTECT_REASON_LABELS: Record = { + auto_baseline_scan_off: 'the automatic baseline scan is off', +} + +function words(code: string): string { + return code.replace(/_/g, ' ') +} + +/** `2026-10-21T06:00:00Z` -> `2026-10-21 06:00 UTC`; anything unparsable stays as sent. */ +function formatWhen(iso: string): string { + const t = Date.parse(iso) + if (Number.isNaN(t)) return iso + return `${new Date(t).toISOString().slice(0, 16).replace('T', ' ')} UTC` +} + +function refreshRowValue(r: BundleRefresh): string { + const outcome = r.last_outcome + let text = outcome ? (OUTCOME_LABELS[outcome] ?? words(outcome)) : 'No cycle has run yet' + if (outcome && r.last_reason) { + text += ` (${REASON_LABELS[r.last_reason] ?? words(r.last_reason)})` + } + if (r.next_check_at) text += `, next check ${formatWhen(r.next_check_at)}` + return text +} + +function reprotectRowValue(r: BundleReprotect): string { + let text = r.state ?? '' + if (r.reason) text += ` (${REPROTECT_REASON_LABELS[r.reason] ?? words(r.reason)})` + if (typeof r.servers_scanned === 'number' && r.servers_scanned > 0) { + text += `, ${r.servers_with_hits ?? 0} of ${r.servers_scanned} ${r.servers_scanned === 1 ? 'server' : 'servers'} matched` + } + if (r.last_completed_at) text += `, last completed ${formatWhen(r.last_completed_at)}` + return text +} + +/** Reasons that mean "the pipeline correctly refused a replay"; never a problem. */ +const QUIET_REJECTIONS = new Set(['downgrade', 'deny_listed']) +const FETCH_CONFIG_REASONS = new Set(['no_url', 'invalid_url', 'no_trusted_signer']) +const FAILURE_WARN_AT = 3 + +/** + * The three B16 conditions, mirroring scanner.BundleDoctorWarnings so the Web UI + * and `mcpproxy doctor` never disagree: a stale corpus on an install that can + * refresh, a refresh stuck on last-known-good, and fetch configured but inactive. + */ +function refreshProblemsOf(b: SignatureBundle): RefreshProblem[] { + const out: RefreshProblem[] = [] + const refresh = b.refresh ?? null + const fetchOn = refresh?.fetch_enabled === true + + if (b.stale === true && (b.source === 'file' || b.source === 'fetch' || fetchOn)) { + const age = typeof b.staleness_days === 'number' ? plural(b.staleness_days, 'day') + ' old' : 'past its freshness threshold' + const threshold = typeof b.stale_after_days === 'number' ? ` (the threshold is ${plural(b.stale_after_days, 'day')})` : '' + out.push({ + kind: 'stale', + explanation: `The signature corpus is ${age}${threshold}, so newer signatures may be missing. Run "mcpproxy security bundle refresh" or check the refresh source.`, + }) + } + + const rej = b.last_rejection + let stuck = '' + if (rej && rej.reason && !QUIET_REJECTIONS.has(rej.reason)) { + const since = Date.parse(b.last_activated_at ?? b.loaded_at ?? '') + const at = Date.parse(rej.at ?? '') + if (!Number.isNaN(at) && (Number.isNaN(since) || at > since)) { + stuck = `A newer candidate was refused (${rejectionLabel(rej.reason)}); the previous bundle is still serving as last-known-good.` + } + } + if (!stuck && (refresh?.consecutive_failures ?? 0) >= FAILURE_WARN_AT) { + stuck = `The last ${refresh!.consecutive_failures} refresh attempts failed; the previous bundle is still serving as last-known-good.` + } + if (stuck) out.push({ kind: 'stuck', explanation: stuck }) + + if ( + fetchOn && + refresh?.fetch_state === 'disabled' && + refresh.fetch_reason && + FETCH_CONFIG_REASONS.has(refresh.fetch_reason) + ) { + out.push({ + kind: 'fetch_inactive', + explanation: `Fetching is enabled but not running: ${REASON_LABELS[refresh.fetch_reason]}. Check the tpa_fetch_url, tpa_active_root and tpa_operator_signers settings.`, + }) + } + return out +} + /** * Formats a bundle descriptor for the Security tab. Returns null when the * daemon reports nothing (an older backend), so the UI renders no empty @@ -201,12 +363,33 @@ export function formatSignatureBundle( if (typeof bundle.staleness_days === 'number') { identity.push({ label: 'Age', value: plural(bundle.staleness_days, 'day') }) } + // Spec 087 FR-017: when the corpus was last checked and activated, how the + // last refresh cycle went, and the re-protection sweep. Each row appears only + // when the daemon reports it, so an older backend renders exactly as before. + if (bundle.last_checked_at) { + identity.push({ label: 'Last checked', value: formatWhen(bundle.last_checked_at) }) + } + if (bundle.last_activated_at) { + identity.push({ label: 'Last activated', value: formatWhen(bundle.last_activated_at) }) + } + const refresh = bundle.refresh ?? null + if (refresh && (refresh.last_outcome || refresh.next_check_at)) { + identity.push({ label: 'Refresh', value: refreshRowValue(refresh) }) + } + if (refresh?.fetch_enabled && refresh.fetch_host) { + identity.push({ label: 'Fetch host', value: refresh.fetch_host }) + } + if (bundle.reprotect?.state) { + identity.push({ label: 'Re-protection', value: reprotectRowValue(bundle.reprotect) }) + } const rej = bundle.last_rejection const lastRejection: LastRejectionSummary | null = rej && rej.reason ? { reason: rej.reason, label: rejectionLabel(rej.reason), detail: rej.detail ?? '', at: rej.at ?? '' } : null + const refreshProblems = refreshProblemsOf(bundle) + return { title: 'Signatures (runnable)', value: String(runnable), @@ -220,6 +403,8 @@ export function formatSignatureBundle( ? { reason: bundle.degraded_reason ?? '', explanation: describeDegradedReason(bundle.degraded_reason) } : null, lastRejection, + refreshProblem: refreshProblems[0] ?? null, + refreshProblems, pinned: bundle.pinned === true, rollback: bundle.rollback === true, } diff --git a/frontend/tests/unit/signature-bundle-refresh.spec.ts b/frontend/tests/unit/signature-bundle-refresh.spec.ts new file mode 100644 index 000000000..02d9cfce5 --- /dev/null +++ b/frontend/tests/unit/signature-bundle-refresh.spec.ts @@ -0,0 +1,256 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { formatSignatureBundle, type SignatureBundle } from '@/utils/signatureBundle' +import SignatureBundlePanelBody from '@/components/security/SignatureBundlePanelBody.vue' + +// Spec 087 FR-017 / Spec 101 US3 (tasks B15, B16): the bundle panel answers +// "is my corpus current, when did it last check, and is the refresh working?". + +const base: SignatureBundle = { source: 'file', path: '/opt/tpa/b.json', runnable_rules: 12, signature_verified: true } + +function rows(b: SignatureBundle): Record { + const out = formatSignatureBundle(b)! + return Object.fromEntries(out.identity.map((r) => [r.label, r.value])) +} + +describe('formatSignatureBundle — refresh and freshness rows', () => { + const full: SignatureBundle = { + ...base, + source: 'fetch', + staleness_days: 9, + last_checked_at: '2026-10-21T06:00:00Z', + last_activated_at: '2026-10-20T06:00:00Z', + refresh: { + fetch_enabled: true, + fetch_host: 'mirror.example.test', + interval_seconds: 86400, + last_trigger: 'timer', + last_outcome: 'fetch_unavailable', + last_reason: 'rate_limited', + consecutive_failures: 1, + next_check_at: '2026-10-22T06:00:00Z', + }, + reprotect: { state: 'idle', servers_scanned: 4, servers_with_hits: 1 }, + } + + it('emits Last checked, Last activated, Refresh and Re-protection rows next to Age', () => { + const out = formatSignatureBundle(full)! + const labels = out.identity.map((r) => r.label) + for (const l of ['Age', 'Last checked', 'Last activated', 'Refresh', 'Re-protection']) { + expect(labels).toContain(l) + } + expect(labels.indexOf('Last checked')).toBeGreaterThan(labels.indexOf('Age')) + const r = rows(full) + expect(r['Last checked']).toBe('2026-10-21 06:00 UTC') + expect(r['Last activated']).toBe('2026-10-20 06:00 UTC') + expect(r['Refresh']).toMatch(/fetch unavailable/i) + expect(r['Refresh']).toMatch(/rate limited/i) + expect(r['Refresh']).toContain('next check 2026-10-22 06:00 UTC') + expect(r['Re-protection']).toMatch(/idle/i) + expect(r['Re-protection']).toContain('1 of 4') + }) + + it('shows the fetch host only while fetch is enabled and a host is known', () => { + expect(rows(full)['Fetch host']).toBe('mirror.example.test') + const off = rows({ ...full, refresh: { ...full.refresh!, fetch_enabled: false } }) + expect(off['Fetch host']).toBeUndefined() + const none = rows({ ...full, refresh: { ...full.refresh!, fetch_host: undefined } }) + expect(none['Fetch host']).toBeUndefined() + }) + + it('labels the closed outcome vocabulary and keeps an unknown outcome visible', () => { + const outcome = (o: string, reason?: string) => + rows({ ...base, refresh: { fetch_enabled: false, interval_seconds: 86400, last_outcome: o, last_reason: reason } })['Refresh'] + expect(outcome('activated')).toMatch(/activated/i) + // Neutral wording: neither outcome proves the serving corpus is current. + expect(outcome('unchanged')).toBe('No change') + expect(outcome('not_modified')).toBe('Remote not modified') + expect(outcome('unchanged')).not.toMatch(/up to date/i) + expect(outcome('not_modified')).not.toMatch(/up to date/i) + expect(outcome('rejected')).toMatch(/refused/i) + expect(outcome('channel_invalid')).toMatch(/channel/i) + expect(outcome('superseded')).toMatch(/superseded/i) + expect(outcome('disabled', 'no_trusted_signer')).toMatch(/trusted signer/i) + expect(outcome('some_future_outcome')).toContain('some future outcome') + }) + + it('describes a suspended re-protection sweep with its reason', () => { + const r = rows({ ...base, reprotect: { state: 'suspended', reason: 'auto_baseline_scan_off', servers_scanned: 0, servers_with_hits: 0 } }) + expect(r['Re-protection']).toMatch(/suspended/i) + expect(r['Re-protection']).toMatch(/baseline scan/i) + }) + + it('emits none of the new rows for a legacy descriptor', () => { + const r = rows({ source: 'embedded', runnable_rules: 6, staleness_days: 4 }) + for (const l of ['Last checked', 'Last activated', 'Refresh', 'Re-protection', 'Fetch host']) { + expect(r[l]).toBeUndefined() + } + expect(r['Age']).toBe('4 days') + }) + + it('leaves an unparsable timestamp as sent rather than inventing a date', () => { + expect(rows({ ...base, last_checked_at: 'not-a-date' })['Last checked']).toBe('not-a-date') + }) +}) + +describe('formatSignatureBundle — refreshProblem', () => { + const activated = '2026-10-20T06:00:00Z' + const newer = '2026-10-21T01:00:00Z' + const older = '2026-10-10T01:00:00Z' + + it('is null for a healthy bundle and for a legacy descriptor', () => { + expect(formatSignatureBundle({ ...base, staleness_days: 3 })!.refreshProblem).toBeNull() + expect(formatSignatureBundle({ source: 'embedded', runnable_rules: 6 })!.refreshProblem).toBeNull() + }) + + it('flags a stale external or fetched bundle, naming age and threshold', () => { + for (const source of ['file', 'fetch']) { + const p = formatSignatureBundle({ ...base, source, staleness_days: 81, stale: true, stale_after_days: 30 })!.refreshProblem + expect(p).not.toBeNull() + expect(p!.kind).toBe('stale') + expect(p!.explanation).toContain('81') + expect(p!.explanation).toContain('30') + } + }) + + it('keeps a stale embedded corpus status-only unless fetch is enabled', () => { + const stale: SignatureBundle = { source: 'embedded', runnable_rules: 6, staleness_days: 81, stale: true, stale_after_days: 30 } + expect(formatSignatureBundle(stale)!.refreshProblem).toBeNull() + expect(formatSignatureBundle({ ...stale, refresh: { fetch_enabled: false, interval_seconds: 86400 } })!.refreshProblem).toBeNull() + const on = formatSignatureBundle({ ...stale, refresh: { fetch_enabled: true, interval_seconds: 86400 } })!.refreshProblem + expect(on?.kind).toBe('stale') + }) + + it('flags a rejection newer than the last activation but not one in the quiet set', () => { + const stuck = formatSignatureBundle({ + ...base, last_activated_at: activated, last_rejection: { reason: 'gate_failure', detail: 'x', at: newer }, + })!.refreshProblem + expect(stuck?.kind).toBe('stuck') + expect(stuck!.explanation).toMatch(/last-known-good/i) + + for (const reason of ['downgrade', 'deny_listed']) { + expect(formatSignatureBundle({ + ...base, last_activated_at: activated, last_rejection: { reason, at: newer }, + })!.refreshProblem).toBeNull() + } + expect(formatSignatureBundle({ + ...base, last_activated_at: activated, last_rejection: { reason: 'tamper', at: older }, + })!.refreshProblem).toBeNull() + }) + + it('compares an embedded bundle’s rejection against process start (loaded_at)', () => { + const e: SignatureBundle = { source: 'embedded', runnable_rules: 6, loaded_at: activated } + expect(formatSignatureBundle({ ...e, last_rejection: { reason: 'tamper', at: newer } })!.refreshProblem?.kind).toBe('stuck') + expect(formatSignatureBundle({ ...e, last_rejection: { reason: 'tamper', at: older } })!.refreshProblem).toBeNull() + }) + + it('flags three or more consecutive fetch failures, not two', () => { + const mk = (n: number) => formatSignatureBundle({ + ...base, refresh: { fetch_enabled: true, interval_seconds: 86400, last_outcome: 'fetch_unavailable', consecutive_failures: n }, + })!.refreshProblem + expect(mk(2)).toBeNull() + expect(mk(3)?.kind).toBe('stuck') + }) + + it('flags fetch enabled but inactive for the three configuration reasons only', () => { + const mk = (reason: string) => formatSignatureBundle({ + source: 'embedded', runnable_rules: 6, + refresh: { fetch_enabled: true, interval_seconds: 86400, last_outcome: 'disabled', last_reason: reason, fetch_state: 'disabled', fetch_reason: reason }, + })!.refreshProblem + for (const reason of ['no_url', 'invalid_url', 'no_trusted_signer']) { + expect(mk(reason)?.kind).toBe('fetch_inactive') + } + expect(mk('fetch_off')).toBeNull() + expect(mk('state_unavailable')).toBeNull() + }) + + it('still flags an inactive fetch leg behind a file activation that owns the cycle outcome', () => { + const out = formatSignatureBundle({ + source: 'file', runnable_rules: 6, + refresh: { + fetch_enabled: true, interval_seconds: 86400, last_outcome: 'activated', + fetch_state: 'disabled', fetch_reason: 'no_trusted_signer', + }, + })! + expect(out.refreshProblem?.kind).toBe('fetch_inactive') + // The merged outcome alone is not enough: a disabled cycle outcome without + // the fetch leg's own state is not reported. + const merged = formatSignatureBundle({ + source: 'embedded', runnable_rules: 6, + refresh: { fetch_enabled: true, interval_seconds: 86400, last_outcome: 'disabled', last_reason: 'no_trusted_signer' }, + })! + expect(merged.refreshProblem).toBeNull() + }) + + it('lists every condition in refreshProblems, the first one in refreshProblem', () => { + const out = formatSignatureBundle({ + ...base, source: 'fetch', staleness_days: 81, stale: true, stale_after_days: 30, + last_activated_at: activated, last_rejection: { reason: 'key_not_trusted', at: newer }, + refresh: { fetch_enabled: true, interval_seconds: 86400, last_outcome: 'disabled', last_reason: 'no_trusted_signer', fetch_state: 'disabled', fetch_reason: 'no_trusted_signer', consecutive_failures: 4 }, + })! + expect(out.refreshProblems.map((p) => p.kind)).toEqual(['stale', 'stuck', 'fetch_inactive']) + expect(out.refreshProblem).toEqual(out.refreshProblems[0]) + }) + + it('never turns a refresh problem into degraded coverage or changes the tone', () => { + const out = formatSignatureBundle({ + ...base, staleness_days: 81, stale: true, stale_after_days: 30, coverage_degraded: false, + })! + expect(out.refreshProblem).not.toBeNull() + expect(out.degraded).toBeNull() + expect(out.tone).toBe('') + }) +}) + +describe('SignatureBundlePanelBody — refresh surface', () => { + const stale: SignatureBundle = { + ...{ source: 'fetch', runnable_rules: 12, signature_verified: true }, + staleness_days: 81, stale: true, stale_after_days: 30, + last_checked_at: '2026-10-21T06:00:00Z', + last_activated_at: '2026-10-20T06:00:00Z', + refresh: { fetch_enabled: true, fetch_host: 'mirror.example.test', interval_seconds: 86400, last_outcome: 'unchanged' }, + reprotect: { state: 'idle', servers_scanned: 2, servers_with_hits: 0 }, + } + + it('renders the freshness rows and a refresh-problem notice', () => { + const wrapper = mount(SignatureBundlePanelBody, { props: { summary: formatSignatureBundle(stale)! } }) + const text = wrapper.text() + expect(text).toContain('Last checked') + expect(text).toContain('Last activated') + expect(text).toContain('Refresh') + expect(text).toContain('Re-protection') + const problem = wrapper.find('[data-test="bundle-refresh-problem"]') + expect(problem.exists()).toBe(true) + expect(problem.text()).toContain('81') + }) + + it('renders no problem notice for a healthy bundle', () => { + const wrapper = mount(SignatureBundlePanelBody, { + props: { summary: formatSignatureBundle({ ...stale, stale: false })! }, + }) + expect(wrapper.find('[data-test="bundle-refresh-problem"]').exists()).toBe(false) + expect(wrapper.text()).toContain('Last checked') + }) + + it('has no mutation button and no unquarantine or approve reference', () => { + const src = readFileSync( + resolve(__dirname, '../../src/components/security/SignatureBundlePanelBody.vue'), + 'utf8' + ) + expect(src).not.toMatch(/unquarantine/i) + expect(src).not.toMatch(/approve/i) + expect(src).not.toMatch(/\$api|fetch\(|axios|\/api\/v1/) + // Only the two digest conveniences are buttons: no "Check now", no rollback. + const buttons = src.match(/