From ed54df9a7bf5c69542bc636cdde567f971e32579 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 12:56:33 +0300 Subject: [PATCH 01/35] docs(spec-101): US1 tasks and 2026-10-20 plan re-validation delta Related #938 --- ROADMAP.md | 4 +- specs/101-tpa-db/tasks.md | 107 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 specs/101-tpa-db/tasks.md diff --git a/ROADMAP.md b/ROADMAP.md index 38ca672ac..78efa6133 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1015,7 +1015,7 @@ graph LR | Planning/docs truth automation | In progress | P2 | — | | | | Discovery-quality eval harness (Spec 065 second half) | In progress | P3 | — | [065-evaluation-foundation](./specs/065-evaluation-foundation/) | | | Spec 114 typed pending-call outcome + call correlation for long-running stdio tool calls | In review | P2 | — | | #1536 | -| tpa-db: versioned TPA signature database for the offline scanner | Todo | P1 | — | [101-tpa-db](./specs/101-tpa-db/) | | +| tpa-db: versioned TPA signature database for the offline scanner | Todo | P1 | 0/30 (0%) | [101-tpa-db](./specs/101-tpa-db/) | | | Auto routing mode: budget-fitted tool surface per session (spec 104) | Todo | P1 | — | [104-auto-routing-mode](./specs/104-auto-routing-mode/) | | | Windows native tray app `MCP-43` | Todo | P2 | — | | | | Remote access tunnel (feature-flagged MVP, spec 089) | Todo | P2 | — | [089-remote-access-tunnel](./specs/089-remote-access-tunnel/) | | @@ -1156,7 +1156,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/) | — | — | +| [101-tpa-db](./specs/101-tpa-db/) | `drafted` | 0/30 (0%) | | [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/specs/101-tpa-db/tasks.md b/specs/101-tpa-db/tasks.md new file mode 100644 index 000000000..54ceefe4a --- /dev/null +++ b/specs/101-tpa-db/tasks.md @@ -0,0 +1,107 @@ +# Tasks: Spec 101 tpa-db — User Story 1 (signed format + downgrade-proof loader) + +**Input**: `/specs/101-tpa-db/` (spec.md, plan.md, research.md, data-model.md, contracts/, quickstart.md) +**Scope of this file**: User Story 1 only, one PR against `main`. US2 (seed corpus, `signatures[]`, `cmd/scan-eval --bundle`, contributor docs) and US3 (publication channel, Spec 087 fetch/tick, post-activation re-scan) are listed at the end as **out of scope** so nothing is silently dropped. + +**TDD rule (constitution V)**: every `test` task is written first and must FAIL for the stated behavioural reason on a build that compiles (add a stub where a new symbol is needed — a missing symbol is a build failure, not a bite). Prove bite by reverting the code hunk, never by deleting symbols. + +**Format**: `[ID] [P?] Description`. `[P]` = parallelizable with the other `[P]` tasks of the same wave (disjoint files). + +--- + +## Plan re-validation (2026-10-20 delta note) + +plan.md was written 2026-08-26 against Go 1.25.5. Re-validated against `main` @ 1b5c0d17b (Go 1.26.0). What no longer matches, and what this PR does instead: + +| # | plan.md says | Tree today | This PR | +|---|---|---|---| +| Δ1 | Go 1.25.5 | `go.mod` is `go 1.26.0` | Re-assert the `encoding/json` duplicate-key and lenient-base64 quirks as tests, not prose. | +| Δ2 | Reuses "Spec 087 FR-010's atomic in-memory swap" | No such swap: 087 is unimplemented; the only mechanism is the `activeBundleMu` RWMutex pair in `tpa_bundle_source.go` | US1 owns the activation seam: one `activationMu` serializes every pipeline run; `(check, info)` publish under one lock. | +| Δ3 | `scanner/tpa_bundle_state.go` reads/writes "through storage" | Import cycle: `internal/storage` imports `scanner` | State types + a `TPABundleStateStore` interface live in `scanner`; `*storage.Manager` implements it (house pattern: `IncarnationStorage`, `ServerApprovalCommitter`). | +| Δ4 | Loader wired through the scanner `Service` | The bundle also loads where no `Service` exists (stdio, `server.go:259`), and loads twice per startup/reload | Package-level `scanner.SetBundleRuntime(store, dataDir)` injected right after `runtime.New`; activation is idempotent. | +| Δ5 | "the file watcher picks the bundle up" (quickstart) | Only `mcp_config.json` is watched | `POST /api/v1/security/bundle/reload` + `mcpproxy security bundle reload` run the same single-flight pipeline (not a second lifecycle; FR-018). Quickstart corrected. | +| Δ6 | `coverageOK` is a one-line edit | The async admission path (`maybeAutoApproveScanSettled`) never sees coverage, so a degraded install would still auto-approve new `scan`-mode servers | Degrade both seams: `coverageOK` in `inprocess.go` and `shouldAutoApproveScanSettled` in `server.go`. | +| Δ7 | Windows gets an explicitly weaker guarantee | `internal/codescripts/open_windows.go` already does a handle-based no-follow open (`FILE_FLAG_OPEN_REPARSE_POINT` + `GetFileInformationByHandle`) with `x/sys` already a dependency | Windows reaches parity on the open; the only residual is the parked reader on a stalled network FS (both platforms). | +| Δ8 | Sidecar has five keys (D3, data-model §2, quickstart) | `contracts/sidecar-format.md` §2 is explicit: four keys, `sequence` deliberately removed | Four keys are normative; `sequence_mismatch` is dropped from the vocabulary. | +| Δ9 | Telemetry: four whitelisted `tpa_scanner` keys | Five (v9 funnel added two); `SchemaVersion` is 14 | Bump to 15; typed bundle keys in their own group; whole-tuple rule (research D7). | +| Δ10 | "backend-only, no frontend" | FR-020 requires the Web UI; CLI and UI hard-code `signature_bundle` fields | Status fields added to CLI, REST and the Web UI security page. | +| Δ11 | `make swagger` after the status change | `/security/overview` has no `@Router` annotation | New bundle endpoints carry annotations; `make swagger` regenerates. | +| Δ12 | `cmd/tpa-db` absent from the tree diagram | quickstart needs `keygen` + `sign` | `cmd/tpa-db keygen|sign|verify` ship in US1; `validate` and the gate-before-sign are US2. | +| Δ13 | Config keys `tpa_operator_keys`, `tpa_active_authority` | `GET /api/v1/config` masks any key containing `KEY` or `AUTH` | Renamed to `tpa_operator_signers`, `tpa_active_root` (public keys are not secrets; masking would break GET→PATCH round-trips). | +| Δ14 | Activation self-check stage (FR-008) | Spec 087 owns it and is unimplemented; the gate lives in `package main` | US1 ships the stage as an injectable hook (no-op default) mapped to `gate_failure`; the real self-check is US2 D8 / Spec 087. | + +## Design decisions recorded for the PR (zero-interruption assumptions) + +- **A1 publisher trust anchors**: `internal/security/scanner/tpa_trust_anchors.go` holds the embedded publisher signer set (**empty today** — no publisher key exists yet), `publisherTrustEpoch = 0`, and a declared fallback baseline. With no publisher key, every publisher-signed candidate is `key_not_trusted`; the operator authority is the only signed path until a release adds a key (US3 release task). Publisher baseline = the embedded manifest's `sequence` when present (the US2 corpus session owns that file and is asked to stamp it), else the declared fallback. +- **A2 reason vocabulary**: `bundlesig` returns the 13 contract codes verbatim; the scanner maps them onto `last_rejection.reason` (`signature_invalid`/`key_id_mismatch` → `tamper`, `sidecar_too_large` → `ceiling_exceeded`, other `sidecar_*` → `sidecar_invalid`) and keeps the fine code in `detail`. Added reasons the spec needs but the vocabulary lacked: `signature_required`, `invalid_bundle`, `epoch_ahead`, `deny_listed`, `read_error`. +- **A3 key lookup order**: union of both trust sets → unknown id `key_not_trusted` → verify (`signature_invalid`) → key of the non-active authority `authority_not_active` (SC-002a b2 must report the latter). +- **A4 pin semantics**: while a pin binds (its authority active, its epoch current) only the pinned digest may activate at or below the watermark; exact deny-list matches are always refused; a candidate strictly above the watermark activates normally and clears the pin. +- **A5 epoch scoping**: per-authority state carries the epoch it is scoped to; any mismatch with the current trusted epoch re-scopes (re-baseline from the authority's baseline, retire pins and deny entries, loud log). +- **A6 unsigned external candidates** skip epoch/sequence/watermark stages entirely (their manifest is never trusted), never advance the watermark, are refused by `require_signed_bundle` or by the ratchet, and activate **degraded**. They get a history row with `signature_verified=false`. +- **A7 "sidecar absent"** means `ENOENT` only; a present-but-invalid sidecar is a rejection, never a fallback to the unsigned path. +- **A8 the never-written FIFO** of SC-011(c) is refused immediately as `not_regular_file` (the `O_NONBLOCK` open never starts a read); the 5 s budget and the one-parked-reader-per-path cap are proven with an injectable blocking reader. +- **A9 config**: `require_signed_bundle` (plain bool, default **false**, maintainer decision D2), `tpa_active_root` (`publisher`|`operator`, default publisher), `tpa_operator_signers` (base64 raw Ed25519 public keys), `tpa_operator_baseline` (`*uint64`, absent = unbaselined → degraded), `tpa_operator_epoch` (`*uint64`, absent = 0). Malformed/duplicate signers fail `ValidateDetailed` (write-time) and are dropped with a log at load (never boot-fatal). No env aliases: the trust root is config-resident by design. +- **A10 no config writes**: all persisted state is BBolt; Spec 116 `Runtime.Commit` is not involved. + +--- + +## Wave 0 — Setup and shared contract (sequential) + +- [ ] T001 Commit this tasks.md. +- [ ] T002 Shared contract (no behaviour): `internal/security/scanner/tpa_bundle_state.go` (state value types, `TPABundleStateStore` interface, authority constants), `tpa_bundle_reasons.go` (rejection + degraded reason constants), `BundleInfo` new fields in `tpa_bundle_source.go`, `SecurityConfig` fields + nil-safe accessors in `internal/config/config.go`. Builds; no test changes. + +## Wave 1 — Independent foundations (parallel, disjoint packages) + +### (a) `internal/security/bundlesig` — sidecar grammar + Ed25519 (FR-003, D3, D10) +- [ ] T010 [P] test: `internal/security/bundlesig/sidecar_test.go` + `testdata/`: `valid.json`/`valid.json.sig`, `accept-trailing-newline`, `accept-all-zero-signature`, one `reject-` per contract row 1–13 asserting the **code**, row-precedence (`{"foo":1}` → `sidecar_missing_field`), exactly 4096 bytes accepted / 4097 `sidecar_too_large`, duplicate key, base64 edge cases (non-canonical trailing bits, embedded newline, 87/89 chars, URL alphabet, missing padding), whitespace/key-order change still verifies (SC-001 non-requirement). +- [ ] T011 [P] impl: `sidecar.go` (total grammar, explicit duplicate-key scan, strict base64 + round-trip), `verify.go` (`TrustSet` with duplicate-id refusal, `KeyID`, `ParsePublicKey`, `Verify`, `CheckKeyIDAgreement`, `SignSidecar`). Pure: no I/O, no globals, stdlib only. +- [ ] T012 [P] test+impl: `cmd/tpa-db` (`keygen`, `sign --bundle --key --out`, `verify --bundle --signer`), stamping nothing into the bundle (operators sign bundles that already carry `sequence`/`key_id`/`trust_epoch`); `sign` refuses a bundle whose manifest `key_id` ≠ the signing key's id. + +### (c) Descriptor-based candidate open (D5, FR-011, SC-011) +- [ ] T020 [P] test: `internal/security/scanner/candidate_open_test.go` (+ `_unix_test.go`): regular file read; size ceiling exactly-at / one-over from the same descriptor; FIFO refused `not_regular_file` without blocking; symlink-to-FIFO and symlink-to-regular refused (no follow); swap-to-FIFO between resolution and open via the test seam refused; missing file → `ENOENT`-classified; injectable blocking reader → `read_timeout` within the budget and a second call on the same path refused fast while the first reader is parked (one parked reader per path). +- [ ] T021 [P] impl: `candidate_open.go` (budgeted reader, parked-reader registry, `readCandidateFile(ctx, path, max)`), `candidate_open_unix.go` (`//go:build unix`: `O_RDONLY|O_NOFOLLOW|O_NONBLOCK|O_CLOEXEC`, `ELOOP`/`EMLINK` → not regular, `fstat`, clear `O_NONBLOCK`), `candidate_open_windows.go` (copy `internal/codescripts/open_windows.go`: `CreateFile` + `FILE_FLAG_OPEN_REPARSE_POINT`, `GetFileInformationByHandle`, refuse reparse points/directories), `candidate_open_other.go` stub. Never `SetReadDeadline`. + +### (d1) Storage for the anti-downgrade state (D6, FR-011a) +- [ ] T030 [P] test: `internal/storage/tpa_bundle_state_test.go`: absent bucket reads `(nil, nil)` and is not created by a read; commit state + history row in one `Update` (fault-inject a marshal failure → neither written); history append-only and ordered across reopen (SC-010 replay of N entries); server removal (`purge`) leaves the bucket intact. +- [ ] T031 [P] impl: `internal/storage/models.go` `TPABundleStateBucket`, `internal/storage/tpa_bundle_state.go` (`BoltDB` raw methods + `Manager` wrappers implementing `scanner.TPABundleStateStore`; nested `history` bucket keyed by big-endian `NextSequence`), `purge.go` "Not purged" note. + +### (f) Config (FR-004, FR-004b, FR-005a, FR-007, FR-009b; D2) +- [ ] T040 [P] test: `internal/config/tpa_bundle_trust_test.go`: defaults (require off, root publisher, epoch 0, baseline absent); accessors nil-safe; `ValidateDetailed` rejects unknown root, malformed signer, duplicate signer ids; boot-time load never fails on them; JSON round-trip preserves explicit `0` baseline/epoch; `GET /api/v1/config` does not mask the new keys (redaction walk). +- [ ] T041 [P] impl: validation in `ValidateDetailed`; `internal/runtime/config_hotreload.go` security clause → `jsonEqual`; settings/nullable fixtures if required by guard tests. + +### (i) Telemetry backstop (FR-021, D7) +- [ ] T050 [P] test: `internal/telemetry/anonymity_test.go` cases: each typed key accepted; free-string `bundle_version` rejected; `sequence` with `other` rejected; publisher line without `sequence` rejected; `source=file` + `signature_verified=false` + publisher line rejected; non-enum `source` rejected; non-bool `signature_verified` rejected; `sequence` > 2^53−1 rejected. +- [ ] T051 [P] impl: `anonymity.go` typed group + whole-tuple rule; `telemetry.go` `TPABundleSnapshot` provider seam + merge into `tpa_scanner` (emitted even when scan counters are zero); `SchemaVersion` 15 + changelog; pinned schema tests updated. + +## Wave 2 — Candidate pipeline, state, rollback, coverage (scanner core; one owner, sequential inside) + +- [ ] T060 test: `internal/security/scanner/tpa_candidate_test.go` — FR-008 stage order with a stage recorder; verify-before-parse proven by a compile counter (zero compiles on tamper); read-once (the verified buffer is the parsed buffer); SC-001 four tamper classes; `require_signed_bundle` + no sidecar → `signature_required`; present-but-invalid sidecar never falls back to unsigned. +- [ ] T061 test: ceilings SC-010a — bundle 8 MiB activates / 8 MiB+1 `ceiling_exceeded`; 2000 rules / 2001; sidecar 4096 / 4097; read > budget `read_timeout` (injected budget); last-known-good serving throughout. +- [ ] T062 test: manifest grammar — `sequence` decimal-string grammar (reject `0`,`01`,`+1`,` 1`,`1e3`, bare number, 21 digits, 2^64; accept `1`, `18446744073709551615`), `trust_epoch` integer, `key_id` agreement (row 13 → `tamper`). +- [ ] T063 test: `tpa_bundle_state_test.go` anti-downgrade — US1 independent test (a)–(i); SC-002 same-authority same-epoch; equal sequence same digest = idempotent no-op, different digest = `downgrade`; SC-002a (a) max-sequence then epoch bump recovers, (a2) stale-epoch replay refused, (b) authorities isolated, (b2) `authority_not_active`, (c) operator baseline after wipe / unbaselined degraded; `epoch_ahead`; FR-004b declaration switch deactivates the serving bundle; SC-012 substitution (lowered watermark, cleared ratchet, forged pin) — only the out-of-data-dir anchors are asserted. +- [ ] T064 test: crash consistency FR-011a — fault injected after LKG write / before commit, after commit / before publish; every recovery lands on a consistent (bundle, watermark) pair; a preserved rollback never loses its deny-list; startup reconciles orphan `tpa-lkg/*` files; a substituted LKG file (digest mismatch) falls back to embedded. +- [ ] T065 test: `tpa_bundle_rollback_test.go` — FR-010/SC-010: rollback to embedded and to a named file by full digest; 12-hex-prefix collision fixture refused `rollback_target_mismatch`; watermark unchanged; deny-listed release not re-activated by the next reload; operator target measured against the operator baseline; baseline-less operator rollback proceeds degraded; unpin returns to watermark governance; N-activation history replay across restart. +- [ ] T066 test: coverage `inprocess_coverage_test.go` — the four degrade conditions and the must-NOT-degrade cases (fresh install on embedded; embedded ≥ publisher watermark after the external file was removed; operator root declared but never activated; operator watermark 900 vs embedded 100); held reason `scan_coverage` end to end through `ScanToolMetadataVerdict`. +- [ ] T067 impl: `tpa_candidate.go` (source-neutral `Candidate{Bundle, Sidecar []byte; Source; Path}` + pure `decide` + `activate`), `tpa_bundle_source.go` (thin file-drop adapter; `ConfigureBundle` kept as a wrapper; `ConfigureBundleFromConfig`; `SetBundleRuntime`; idempotence; `last_rejection` carried across loads), `tpa_bundle.go` (manifest fields, sequence grammar, rule-count ceiling), `tpa_bundle_state.go` (seeding, epoch scoping, ratchet, pin/deny), `tpa_bundle_lkg.go` (content-addressed LKG: temp → fsync → rename → fsync dir → commit → publish; reconcile), `tpa_bundle_rollback.go`, `tpa_trust_anchors.go`, `inprocess.go` (`coverageOK` degrade), self-check hook. + +## Wave 3 — Surfaces and wiring (parallel, disjoint files) + +- [ ] T070 [P] test+impl: server wiring `internal/server/server.go` — `SetBundleRuntime(rt.StorageManager(), cfg.DataDir)` before the first configure; `configureTPABundle` → `ConfigureBundleFromConfig`; `shouldAutoApproveScanSettled` refuses while `scanner.BundleCoverageDegraded()`; telemetry provider wired. Tests under the CI skip regex. +- [ ] T071 [P] test+impl: REST `internal/httpapi` — `GET /security/bundle`, `GET /security/bundle/history`, `POST /security/bundle/rollback`, `POST /security/bundle/unpin`, `POST /security/bundle/reload`; admin-read for GETs, a new agent-denied server op for mutations; swagger annotations; `make swagger`; gating/route-table guard tests. +- [ ] T072 [P] test+impl: CLI `cmd/mcpproxy/security_cmd.go` — `security bundle [-o json|yaml]`, `bundle history`, `bundle rollback (--embedded | --path P) --digest <64 hex>`, `bundle unpin`, `bundle reload`; `signatureBundleLines` shows the new fields and a degraded line with its reason. +- [ ] T073 [P] test+impl: Web UI `frontend/src/utils/signatureBundle.ts`, `frontend/src/views/Security.vue` — full digest on demand, sequence/authority/epoch/verified, degraded banner with reason, last rejection; vitest in `frontend/tests/unit/`. + +## Wave 4 — Verify, review, ship + +- [ ] T080 `./scripts/test-api-e2e.sh`; `go test -race ./internal/security/... ./internal/storage/... ./internal/config/... ./internal/telemetry/...`; the CLAUDE.md skip-regex race run over `internal/server`/`httpapi`; golangci-lint v2 bare and `--build-tags server`. +- [ ] T081 Review workflow (dimensions → 3 refuters per finding → fix confirmed). +- [ ] T082 Cross-model review (opencode Sol 6.1; ≤10 rounds). +- [ ] T083 Docs: `docs/features/tpa-bundle-signing.md` (operator: verify, drop, reload, roll back, read status, air-gapped walkthrough, Windows/NFS residual), `docs/features/security-quarantine.md` + tool-scanner docs (degraded states), `docs/configuration.md` (new keys), quickstart.md corrections (Δ5, Δ8, Δ13, sequence ≥ baseline), `CLAUDE.md` Recent Changes, `roadmap.yaml` + `scripts/gen-roadmap.py`. +- [ ] T084 Open the PR; tick this file. + +--- + +## Out of scope for this PR + +- **US2 (sibling session)**: `signatures[]` section parsing and integrity (D4, `contracts/bundle-signatures-section.md`); the seed corpus in `internal/security/scanner/bundled/scanner-bundle.json` (**including stamping its `sequence`/`trust_epoch`/`generated_at`** — handed over); `cmd/scan-eval --bundle` + `BundleCheck` registration + `categoryCheck` entries + D9 negative control; `cmd/tpa-db validate` and gate-before-sign; contributor docs; `.github/workflows/eval.yml`. +- **US3 / Spec 087**: publication channel (D1), network fetch + daily tick + single-flight lifecycle, the real activation self-check, post-activation re-scan (FR-019), fleet-freshness dashboards (SC-006/SC-007 fetch clause), shipping the first publisher key in a release. From a39651539b14918cbd6eeb61565d45f5a2501287 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 12:58:09 +0300 Subject: [PATCH 02/35] feat(security): Spec 101 US1 shared contract (state types, reasons, status and config fields) Related #938 --- internal/config/config.go | 80 ++++++++++ .../security/scanner/tpa_bundle_reasons.go | 81 ++++++++++ .../security/scanner/tpa_bundle_source.go | 44 +++++ internal/security/scanner/tpa_bundle_state.go | 151 ++++++++++++++++++ 4 files changed, 356 insertions(+) create mode 100644 internal/security/scanner/tpa_bundle_reasons.go create mode 100644 internal/security/scanner/tpa_bundle_state.go diff --git a/internal/config/config.go b/internal/config/config.go index 4fa37cbc5..1a94f5867 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3214,6 +3214,38 @@ type SecurityConfig struct { TPABundlePath string `json:"tpa_bundle_path,omitempty" mapstructure:"tpa-bundle-path"` // (see EnvTPABundlePath for the env override that outranks this field) + // --- Spec 101 US1: signed TPA bundle trust root --- + // + // These keys are config-resident ON PURPOSE: the attacker this design + // defends against can write the bundle path (and possibly the data + // directory) but not mcp_config.json. None of them has an env alias. + + // RequireSignedBundle makes the detached signature sidecar mandatory for + // file-dropped bundles (FR-007). Default false (maintainer decision D2): + // existing unsigned drops keep working, but an unsigned external bundle + // always runs with DEGRADED coverage (scan-mode auto-approval suspended). + RequireSignedBundle bool `json:"require_signed_bundle" mapstructure:"require-signed-bundle"` + // TPAActiveRoot declares which signing authority's bundles may activate + // (FR-004b): "publisher" (default, the keys embedded in this binary) or + // "operator" (TPAOperatorSigners). A bundle verified by the other root is + // refused as authority_not_active, so whoever can write the bundle path + // cannot choose the authority. + TPAActiveRoot string `json:"tpa_active_root,omitempty" mapstructure:"tpa-active-root"` + // TPAOperatorSigners lists operator Ed25519 public keys, each the standard + // base64 encoding of the 32 raw key bytes (FR-004). Their key ids are the + // lowercase-hex SHA-256 of those bytes. Malformed or duplicate entries are + // rejected on write and dropped (with a log) on load. + TPAOperatorSigners []string `json:"tpa_operator_signers,omitempty" mapstructure:"tpa-operator-signers"` + // TPAOperatorBaseline is the lowest operator-authority sequence this install + // will ever accept (FR-009b). It survives a data-directory wipe because it + // lives here. Absent means the operator authority has no anti-downgrade + // floor and an active operator bundle runs DEGRADED. + TPAOperatorBaseline *uint64 `json:"tpa_operator_baseline,omitempty" mapstructure:"tpa-operator-baseline"` + // TPAOperatorEpoch is the operator authority's trust epoch (FR-005a). + // Bumping it (after an operator key compromise) re-baselines the operator + // watermark and retires prior-epoch pins. Absent means 0. + TPAOperatorEpoch *uint64 `json:"tpa_operator_epoch,omitempty" mapstructure:"tpa-operator-epoch"` + // DeepScan is the opt-in "deep scan" layer (Spec 077 US3). It subsumes the // deprecated top-level scanner_fetch_package_source / scanner_disable_no_new_privileges // keys (migrated on load) and gates the heavy Docker-based scanners + source @@ -3291,6 +3323,54 @@ func (sc *SecurityConfig) EffectiveTPABundlePath() string { return sc.TPABundlePath } +// TPA signing roots accepted by SecurityConfig.TPAActiveRoot (spec 101 FR-004b). +const ( + TPARootPublisher = "publisher" + TPARootOperator = "operator" +) + +// IsRequireSignedBundle reports whether file-dropped TPA bundles must carry a +// signature sidecar. Nil-safe; default false (spec 101 D2). +func (sc *SecurityConfig) IsRequireSignedBundle() bool { + return sc != nil && sc.RequireSignedBundle +} + +// EffectiveTPAActiveRoot returns the declared active signing root, defaulting +// to "publisher" (spec 101 FR-004b). An unrecognised value is returned verbatim +// so the loader can refuse every candidate rather than guess. +func (sc *SecurityConfig) EffectiveTPAActiveRoot() string { + if sc == nil || sc.TPAActiveRoot == "" { + return TPARootPublisher + } + return sc.TPAActiveRoot +} + +// EffectiveTPAOperatorSigners returns the configured operator public keys +// (base64), unvalidated. Nil-safe. +func (sc *SecurityConfig) EffectiveTPAOperatorSigners() []string { + if sc == nil { + return nil + } + return sc.TPAOperatorSigners +} + +// EffectiveTPAOperatorBaseline returns the operator baseline and whether one is +// declared (spec 101 FR-009b: absent ⇒ operator authority runs degraded). +func (sc *SecurityConfig) EffectiveTPAOperatorBaseline() (uint64, bool) { + if sc == nil || sc.TPAOperatorBaseline == nil { + return 0, false + } + return *sc.TPAOperatorBaseline, true +} + +// EffectiveTPAOperatorEpoch returns the operator trust epoch, 0 when absent. +func (sc *SecurityConfig) EffectiveTPAOperatorEpoch() uint64 { + if sc == nil || sc.TPAOperatorEpoch == nil { + return 0 + } + return *sc.TPAOperatorEpoch +} + // IsAutoBaselineScanEnabled reports whether mcpproxy may run the automatic, // informational Pass-1 baseline scan (new-server admission scan + the one-shot // baseline sweep). Default is ENABLED: a nil SecurityConfig, or an unset diff --git a/internal/security/scanner/tpa_bundle_reasons.go b/internal/security/scanner/tpa_bundle_reasons.go new file mode 100644 index 000000000..abd569bce --- /dev/null +++ b/internal/security/scanner/tpa_bundle_reasons.go @@ -0,0 +1,81 @@ +package scanner + +// Bundle rejection reasons surfaced in BundleInfo.LastRejection.Reason (spec +// 101 FR-020; contracts/bundle-status-surface.md §2). The Reason is the only +// field anything branches on; the fine-grained cause (the bundlesig contract +// code, which ceiling and by how much, which key id, which epoch) goes in +// Detail. Every rejection keeps last-known-good serving. +const ( + // RejectTamper: the signature did not verify over the raw bytes, or the + // manifest / sidecar / verifying-key ids disagree (sidecar contract rows 12 + // and 13; SC-001's tamper classes). + RejectTamper = "tamper" + // RejectDowngrade: sequence below this authority's watermark for this + // epoch, or an equal sequence with a different digest. + RejectDowngrade = "downgrade" + // RejectStaleEpochReplay: manifest epoch below the current trusted epoch. + RejectStaleEpochReplay = "stale_epoch_replay" + // RejectEpochAhead: manifest epoch above the current trusted epoch — this + // binary (or this config) is older than the publisher's recovery release; + // update mcpproxy. Does not demote coverage (FR-009a allows two conditions). + RejectEpochAhead = "epoch_ahead" + // RejectUnsignedRatchet: an unsigned candidate after a signature-verified + // bundle has activated on this install (FR-009 ratchet). + RejectUnsignedRatchet = "unsigned_refused_by_ratchet" + // RejectSignatureRequired: require_signed_bundle is on and the sidecar is + // absent. + RejectSignatureRequired = "signature_required" + // RejectAuthorityNotActive: verified against a real key of the NON-active + // root (FR-004b). + RejectAuthorityNotActive = "authority_not_active" + // RejectRollbackTargetMismatch: the loaded artifact's full digest is not the + // digest the operator named (FR-010). + RejectRollbackTargetMismatch = "rollback_target_mismatch" + // RejectDenyListed: the candidate is a release the operator rolled away + // from (FR-010 deny-list). + RejectDenyListed = "deny_listed" + // RejectGateFailure: the activation self-check refused the candidate. + RejectGateFailure = "gate_failure" + // RejectCeilingExceeded: one of FR-011's limits (bundle 8 MiB, 2000 rules, + // sidecar 4 KiB) — which one, and the observed value, in Detail. + RejectCeilingExceeded = "ceiling_exceeded" + // RejectNotRegularFile: the candidate (or its sidecar) is a FIFO, device, + // socket, directory, or a symlink. + RejectNotRegularFile = "not_regular_file" + // RejectReadTimeout: the per-candidate read budget expired; the + // single-flight slot was released. + RejectReadTimeout = "read_timeout" + // RejectReadError: the candidate could not be opened or read (missing file, + // permission denied, I/O error). + RejectReadError = "read_error" + // RejectSidecarInvalid: the sidecar violates its grammar (bundlesig + // sidecar_* codes other than sidecar_too_large). + RejectSidecarInvalid = "sidecar_invalid" + // RejectKeyNotTrusted: the sidecar key id is in neither trust set. + RejectKeyNotTrusted = "key_not_trusted" + // RejectInvalidBundle: the bundle failed to parse, carries a malformed + // manifest field (sequence grammar, trust_epoch), an unsupported version, a + // pattern that does not compile, or no runnable rule. + RejectInvalidBundle = "invalid_bundle" +) + +// Degraded-coverage reasons (FR-007, FR-009a, FR-009b; +// contracts/bundle-status-surface.md §3). While any of these holds, scan-mode +// auto-approval is suspended and changes route to human review. +const ( + DegradedUnsignedExternalActive = "unsigned_external_active" + DegradedEmbeddedBelowWatermark = "embedded_below_watermark" + DegradedOperatorBundleMissing = "operator_bundle_missing" + DegradedOperatorAuthorityUnbaselined = "operator_authority_unbaselined" +) + +// FR-011 load ceilings. A candidate AT a ceiling loads; one unit over is +// rejected whole. Design may tighten these, never loosen them. +const ( + // MaxBundleBytes is the bundle byte ceiling (8 MiB). + MaxBundleBytes = 8 << 20 + // MaxBundleRules is the rules[] entry ceiling (all entries, runnable or not). + MaxBundleRules = 2000 + // MaxSidecarBytes is the sidecar byte ceiling (4 KiB). + MaxSidecarBytes = 4 << 10 +) diff --git a/internal/security/scanner/tpa_bundle_source.go b/internal/security/scanner/tpa_bundle_source.go index ca0df3526..500e672a7 100644 --- a/internal/security/scanner/tpa_bundle_source.go +++ b/internal/security/scanner/tpa_bundle_source.go @@ -58,6 +58,50 @@ type BundleInfo struct { // never leave the scanner with no signatures — but the failure is surfaced // here rather than swallowed. LoadError string `json:"load_error,omitempty"` + + // --- Spec 101 FR-020 (contracts/bundle-status-surface.md) --- + + // FullDigest is the complete lowercase-hex SHA-256 of the bundle bytes — + // the ONLY value rollback accepts (FR-010). Fingerprint stays the 12-hex + // human label beside it. + FullDigest string `json:"full_digest,omitempty"` + // Sequence is the manifest sequence as published (decimal string), "" when + // the bundle carries none. + Sequence string `json:"sequence,omitempty"` + // Authority is the signing root serving this bundle: "publisher" (the + // embedded default, or verified against a publisher key), "operator", or "" + // for an unsigned external bundle. + Authority string `json:"authority,omitempty"` + // TrustEpoch is the epoch the bundle was published under. + TrustEpoch uint64 `json:"trust_epoch"` + // SignatureVerified reports whether the active bundle's detached signature + // verified. Always false for the embedded default (it is binary-trusted). + SignatureVerified bool `json:"signature_verified"` + // StalenessDays is the whole days since GeneratedAt (nil when unknown). + StalenessDays *int `json:"staleness_days,omitempty"` + // CoverageDegraded suspends scan-mode auto-approval; DegradedReason says + // why (one of the Degraded* constants). + CoverageDegraded bool `json:"coverage_degraded"` + DegradedReason string `json:"degraded_reason,omitempty"` + // Pinned is true while an operator rollback pin governs activation. + Pinned bool `json:"pinned,omitempty"` + // Rollback is true when the active bundle was activated by an explicit + // rollback. + Rollback bool `json:"rollback,omitempty"` + // LastRejection is the most recent refused candidate. It is retained + // across a later successful load so "what did it refuse, and when" + // survives. + LastRejection *BundleRejection `json:"last_rejection,omitempty"` +} + +// BundleRejection is one refused candidate (FR-020). +type BundleRejection struct { + // Reason is one of the Reject* constants; the only field to branch on. + Reason string `json:"reason"` + // Detail is a short human string (which ceiling, which key id, which + // epoch, the bundlesig contract code). Never parsed. + Detail string `json:"detail,omitempty"` + At time.Time `json:"at"` } // activeBundle is the process-wide corpus in use. It is replaced wholesale by diff --git a/internal/security/scanner/tpa_bundle_state.go b/internal/security/scanner/tpa_bundle_state.go new file mode 100644 index 000000000..c2ff7d9a2 --- /dev/null +++ b/internal/security/scanner/tpa_bundle_state.go @@ -0,0 +1,151 @@ +package scanner + +import "time" + +// Signing authorities (spec 101 FR-004/FR-004a). An authority is a NAMESPACE +// for anti-downgrade state, not a key: every publisher key — including keys +// being rotated in or out — belongs to the one publisher authority, so a +// rotated-in key inherits the existing watermark instead of starting fresh. +const ( + // AuthorityPublisher is the binary-embedded publisher signer set. + AuthorityPublisher = "publisher" + // AuthorityOperator is the config-resident operator signer set + // (security.tpa_operator_signers). + AuthorityOperator = "operator" +) + +// TPABundleStateSchemaVersion versions the persisted TPABundleState record. +const TPABundleStateSchemaVersion = 1 + +// TPABundleState is the per-install anti-downgrade record (spec 101 FR-009, +// FR-010, FR-011a; data-model §4). It is persisted as ONE value and written in +// ONE storage transaction together with the activation-history row of the +// activation that produced it, so a crash can never pair a new active bundle +// with an old watermark, or a preserved rollback with a lost deny-list. +// +// Everything here is data-directory resident and therefore BEST-EFFORT against +// an attacker who can substitute config.db (spec threat-model boundary, +// SC-012): the guarantees that survive that attacker are the ones anchored +// outside the data dir — the publisher baseline re-seeded from the binary, the +// config-resident operator baseline, require_signed_bundle and the active-root +// declaration. Code MUST NOT present the ratchet, watermark, pin or deny-list +// as surviving state substitution. +type TPABundleState struct { + SchemaVersion int `json:"schema_version"` + + // SignatureRatchet is set once ANY signature-verified external bundle has + // activated on this install. From then on unsigned candidates are refused + // regardless of require_signed_bundle (FR-009). + SignatureRatchet bool `json:"signature_ratchet"` + + // Active is the external bundle currently serving, or nil when the + // embedded default serves. Its digest names the content-addressed + // last-known-good file tpa-lkg/.json in the data directory. + Active *TPABundleActiveRecord `json:"active,omitempty"` + + // Authorities holds per-authority scoped state keyed by AuthorityPublisher + // / AuthorityOperator. A missing entry means "no state yet" and is seeded + // from the authority's baseline (FR-009b) on first use. + Authorities map[string]*TPAAuthorityState `json:"authorities,omitempty"` +} + +// TPABundleActiveRecord describes the serving external bundle (FR-011a). +type TPABundleActiveRecord struct { + // Authority is AuthorityPublisher / AuthorityOperator for a + // signature-verified bundle, "" for an unsigned one. + Authority string `json:"authority,omitempty"` + // TrustEpoch is the manifest epoch (signed bundles only). + TrustEpoch uint64 `json:"trust_epoch"` + // Sequence is the manifest sequence (signed bundles only; 0 when unsigned). + Sequence uint64 `json:"sequence"` + // FullDigest is the lowercase-hex SHA-256 of the exact bundle bytes. + FullDigest string `json:"full_digest"` + // SidecarDigest is the lowercase-hex SHA-256 of the sidecar bytes ("" when + // unsigned) — part of the idempotence key. + SidecarDigest string `json:"sidecar_digest,omitempty"` + SignatureVerified bool `json:"signature_verified"` + Source string `json:"source"` + Path string `json:"path,omitempty"` + GeneratedAt string `json:"generated_at,omitempty"` + ActivatedAt time.Time `json:"activated_at"` + // Rollback is true when this bundle was activated by an explicit + // operator rollback (FR-010) rather than normal watermark governance. + Rollback bool `json:"rollback,omitempty"` +} + +// TPAAuthorityState is one authority's anti-downgrade state, scoped to Epoch +// (FR-005a: every comparison is (authority, epoch, sequence), epoch first). +// When the current trusted epoch for the authority differs from Epoch the whole +// record is re-scoped: the watermark re-baselines from the authority's baseline +// and prior-epoch pins and deny entries are retired (loudly logged). +type TPAAuthorityState struct { + Epoch uint64 `json:"epoch"` + // Watermark is the highest sequence ever activated for (authority, Epoch), + // never below the authority's baseline. Monotonic: rollback does not lower + // it (FR-010). + Watermark uint64 `json:"watermark"` + // WatermarkSetBy is the full digest of the bundle that set Watermark ("" when + // the watermark is only the seeded baseline). + WatermarkSetBy string `json:"watermark_set_by,omitempty"` + // ExternalEverActivated records that an external bundle of this authority + // has activated in this epoch — FR-009a(b) (`operator_bundle_missing`) needs + // it to tell "operator bundle lost" from "operator root declared, nothing + // ever activated". + ExternalEverActivated bool `json:"external_ever_activated,omitempty"` + // Pin is the FR-010 rollback pin. It binds only while this authority is the + // active root and Epoch equals the current trusted epoch; switching roots + // suspends (never deletes) it. + Pin *TPABundlePin `json:"pin,omitempty"` + // DenyList holds the releases rolled away from (FR-010). Same scoping as Pin. + DenyList []TPABundleDenyEntry `json:"deny_list,omitempty"` +} + +// TPABundlePin is an operator-pinned rollback target, identified by full digest. +type TPABundlePin struct { + Sequence uint64 `json:"sequence"` + FullDigest string `json:"full_digest"` + // Embedded is true when the pin is the embedded default. + Embedded bool `json:"embedded,omitempty"` + PinnedAt time.Time `json:"pinned_at"` +} + +// TPABundleDenyEntry is one deny-listed release, keyed (authority, epoch, +// sequence, digest) — the authority and epoch are the enclosing +// TPAAuthorityState's. +type TPABundleDenyEntry struct { + Sequence uint64 `json:"sequence"` + FullDigest string `json:"full_digest"` + DeniedAt time.Time `json:"denied_at"` +} + +// TPABundleActivation is one append-only activation-history row (FR-010, +// SC-010; contracts/bundle-status-surface.md §5). The history makes a +// legitimate rollback POSSIBLE — it is how an operator learns the full digest +// of a known-good artifact — it never AUTHORIZES one. +type TPABundleActivation struct { + Authority string `json:"authority,omitempty"` + TrustEpoch uint64 `json:"trust_epoch"` + // Sequence is the decimal-string sequence as published ("" when unsigned or + // embedded without one). + Sequence string `json:"sequence,omitempty"` + FullDigest string `json:"full_digest"` + GeneratedAt string `json:"generated_at,omitempty"` + ActivatedAt time.Time `json:"activated_at"` + Source string `json:"source"` + SignatureVerified bool `json:"signature_verified"` + Rollback bool `json:"rollback,omitempty"` +} + +// TPABundleStateStore persists TPABundleState and its activation history. It is +// implemented by *storage.Manager (storage imports this package, so the +// interface lives here — the same pattern as IncarnationStorage). +type TPABundleStateStore interface { + // LoadTPABundleState returns the persisted state, or (nil, nil) when none + // has ever been written. A read MUST NOT create the bucket. + LoadTPABundleState() (*TPABundleState, error) + // CommitTPABundleState writes state and, when entry is non-nil, appends it + // to the activation history — both in ONE storage transaction (FR-011a). + CommitTPABundleState(state *TPABundleState, entry *TPABundleActivation) error + // ListTPABundleHistory returns every history row, oldest first. + ListTPABundleHistory() ([]TPABundleActivation, error) +} From 2f1e4737f87288cd7acb09c16e4e7041504e262c Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 13:01:54 +0300 Subject: [PATCH 03/35] feat(security): Spec 101 US1 persist TPA bundle anti-downgrade state in BBolt Adds the tpa_bundle_state bucket (lazy create, never by a read), atomic state + activation-history commit, and *storage.Manager implementing scanner.TPABundleStateStore. Server removal leaves the bucket intact. Related #938 --- internal/storage/models.go | 7 + internal/storage/purge.go | 5 +- internal/storage/tpa_bundle_state.go | 164 +++++++++++++++ internal/storage/tpa_bundle_state_test.go | 239 ++++++++++++++++++++++ 4 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 internal/storage/tpa_bundle_state.go create mode 100644 internal/storage/tpa_bundle_state_test.go diff --git a/internal/storage/models.go b/internal/storage/models.go index e715f6610..1f2542095 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -71,6 +71,13 @@ const ( // records. ToolBaselineDecisionsBucket = "tool_baseline_decisions" + // TPABundleStateBucket holds the installation-wide Spec 101 anti-downgrade + // state of the signed TPA bundle: key "state" (scanner.TPABundleState) and a + // nested "history" bucket of activation rows. It is keyed by signing + // authority, not by server, so server removal never purges it. Created + // lazily on the first write, never by a read. + TPABundleStateBucket = "tpa_bundle_state" + // Onboarding wizard bucket (Spec 046) OnboardingBucket = "onboarding" ) diff --git a/internal/storage/purge.go b/internal/storage/purge.go index 024e2c969..a8c4b7516 100644 --- a/internal/storage/purge.go +++ b/internal/storage/purge.go @@ -23,7 +23,10 @@ import ( // all, and a later re-add starts with nothing. // // Not purged (A5): activity records (audit history), agent tokens, client -// credentials and toolstats (aggregate counters, not trust). +// credentials and toolstats (aggregate counters, not trust), and the Spec 101 +// TPA bundle state (TPABundleStateBucket): it is installation-wide anti-downgrade +// state keyed by signing authority, not by server, so removing a server must +// never reset a watermark, pin or deny-list. // Purge-set report keys that are not bucket names. const ( diff --git a/internal/storage/tpa_bundle_state.go b/internal/storage/tpa_bundle_state.go new file mode 100644 index 000000000..ee82e21ae --- /dev/null +++ b/internal/storage/tpa_bundle_state.go @@ -0,0 +1,164 @@ +package storage + +import ( + "encoding/binary" + "encoding/json" + "fmt" + + "go.etcd.io/bbolt" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/security/scanner" +) + +// Spec 101 US1 (FR-009, FR-010, FR-011a, SC-010): durable anti-downgrade state +// of the signed TPA bundle. +// +// Layout of TPABundleStateBucket: +// +// "state" -> JSON scanner.TPABundleState +// "history" (bucket) -> big-endian uint64 NextSequence -> JSON scanner.TPABundleActivation +// +// The bucket is created lazily by the first write and NEVER by a read, so an +// install that never activates an external bundle keeps a config.db that an +// older binary reads unchanged. The state and the history row of the activation +// that produced it are written in ONE transaction: a crash can never pair a new +// active bundle with an old watermark. + +const ( + tpaBundleStateKey = "state" + tpaBundleHistoryBucket = "history" +) + +var _ scanner.TPABundleStateStore = (*Manager)(nil) + +// LoadTPABundleState returns the persisted state, or (nil, nil) when none has +// ever been written. +func (m *Manager) LoadTPABundleState() (*scanner.TPABundleState, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.db.LoadTPABundleState() +} + +// CommitTPABundleState writes state and, when entry is non-nil, appends it to +// the activation history in one transaction. +func (m *Manager) CommitTPABundleState(state *scanner.TPABundleState, entry *scanner.TPABundleActivation) error { + m.mu.Lock() + defer m.mu.Unlock() + return m.db.CommitTPABundleState(state, entry) +} + +// ListTPABundleHistory returns every activation row, oldest first. +func (m *Manager) ListTPABundleHistory() ([]scanner.TPABundleActivation, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.db.ListTPABundleHistory() +} + +// LoadTPABundleState is the BoltDB form of Manager.LoadTPABundleState. A +// missing bucket or key reads (nil, nil); a value that cannot be decoded is an +// error so callers fail safe instead of treating corruption as "no state". +func (b *BoltDB) LoadTPABundleState() (*scanner.TPABundleState, error) { + var state *scanner.TPABundleState + err := b.db.View(func(tx *bbolt.Tx) error { + bucket := tx.Bucket([]byte(TPABundleStateBucket)) + if bucket == nil { + return nil + } + raw := bucket.Get([]byte(tpaBundleStateKey)) + if raw == nil { + return nil + } + var s scanner.TPABundleState + if err := json.Unmarshal(raw, &s); err != nil { + return fmt.Errorf("decode tpa bundle state: %w", err) + } + state = &s + return nil + }) + if err != nil { + return nil, err + } + return state, nil +} + +// CommitTPABundleState is the BoltDB form of Manager.CommitTPABundleState. Both +// values are marshalled BEFORE the bucket is touched, and any error aborts the +// whole transaction, so neither the state nor the history row is written on +// failure. +func (b *BoltDB) CommitTPABundleState(state *scanner.TPABundleState, entry *scanner.TPABundleActivation) error { + if state == nil { + return fmt.Errorf("commit tpa bundle state: nil state") + } + // Stamp on a copy so a failed commit does not mutate the caller's value. + stamped := *state + if stamped.SchemaVersion == 0 { + stamped.SchemaVersion = scanner.TPABundleStateSchemaVersion + } + stateRaw, err := json.Marshal(&stamped) + if err != nil { + return fmt.Errorf("encode tpa bundle state: %w", err) + } + var entryRaw []byte + if entry != nil { + if entryRaw, err = json.Marshal(entry); err != nil { + return fmt.Errorf("encode tpa bundle activation: %w", err) + } + } + + if err := b.db.Update(func(tx *bbolt.Tx) error { + bucket, err := tx.CreateBucketIfNotExists([]byte(TPABundleStateBucket)) + if err != nil { + return err + } + if err := bucket.Put([]byte(tpaBundleStateKey), stateRaw); err != nil { + return err + } + if entryRaw == nil { + return nil + } + history, err := bucket.CreateBucketIfNotExists([]byte(tpaBundleHistoryBucket)) + if err != nil { + return err + } + seq, err := history.NextSequence() + if err != nil { + return err + } + var key [8]byte + binary.BigEndian.PutUint64(key[:], seq) + return history.Put(key[:], entryRaw) + }); err != nil { + return err + } + // Reflect the stamped version back only after the commit succeeded. + state.SchemaVersion = stamped.SchemaVersion + return nil +} + +// ListTPABundleHistory is the BoltDB form of Manager.ListTPABundleHistory. +// Big-endian sequence keys make cursor order the append order. +func (b *BoltDB) ListTPABundleHistory() ([]scanner.TPABundleActivation, error) { + var out []scanner.TPABundleActivation + err := b.db.View(func(tx *bbolt.Tx) error { + bucket := tx.Bucket([]byte(TPABundleStateBucket)) + if bucket == nil { + return nil + } + history := bucket.Bucket([]byte(tpaBundleHistoryBucket)) + if history == nil { + return nil + } + return history.ForEach(func(_, v []byte) error { + var a scanner.TPABundleActivation + if err := json.Unmarshal(v, &a); err != nil { + return fmt.Errorf("decode tpa bundle activation: %w", err) + } + out = append(out, a) + return nil + }) + }) + if err != nil { + return nil, err + } + return out, nil +} diff --git a/internal/storage/tpa_bundle_state_test.go b/internal/storage/tpa_bundle_state_test.go new file mode 100644 index 000000000..eee5ae5eb --- /dev/null +++ b/internal/storage/tpa_bundle_state_test.go @@ -0,0 +1,239 @@ +package storage + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.etcd.io/bbolt" + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/security/scanner" +) + +func tpaBucketExists(t *testing.T, m *Manager) bool { + t.Helper() + var exists bool + require.NoError(t, m.GetDB().View(func(tx *bbolt.Tx) error { + exists = tx.Bucket([]byte(TPABundleStateBucket)) != nil + return nil + })) + return exists +} + +func tpaFullState() *scanner.TPABundleState { + at := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC) + return &scanner.TPABundleState{ + SchemaVersion: scanner.TPABundleStateSchemaVersion, + SignatureRatchet: true, + Active: &scanner.TPABundleActiveRecord{ + Authority: scanner.AuthorityPublisher, + TrustEpoch: 2, + Sequence: 41, + FullDigest: "aa11", + SidecarDigest: "bb22", + SignatureVerified: true, + Source: "remote", + Path: "/data/tpa-lkg/aa11.json", + GeneratedAt: "2026-10-19T00:00:00Z", + ActivatedAt: at, + Rollback: true, + }, + Authorities: map[string]*scanner.TPAAuthorityState{ + scanner.AuthorityPublisher: { + Epoch: 2, + Watermark: 44, + WatermarkSetBy: "cc33", + ExternalEverActivated: true, + Pin: &scanner.TPABundlePin{Sequence: 41, FullDigest: "aa11", Embedded: false, PinnedAt: at}, + DenyList: []scanner.TPABundleDenyEntry{ + {Sequence: 43, FullDigest: "dd44", DeniedAt: at}, + {Sequence: 44, FullDigest: "ee55", DeniedAt: at.Add(time.Minute)}, + }, + }, + scanner.AuthorityOperator: {Epoch: 0, Watermark: 0}, + }, + } +} + +func tpaEntry(seq int) *scanner.TPABundleActivation { + return &scanner.TPABundleActivation{ + Authority: scanner.AuthorityPublisher, + TrustEpoch: 1, + Sequence: fmt.Sprintf("%d", seq), + FullDigest: fmt.Sprintf("digest-%d", seq), + ActivatedAt: time.Date(2026, 10, 20, 12, seq, 0, 0, time.UTC), + Source: "remote", + SignatureVerified: true, + } +} + +func TestTPABundleState_AbsentBucketReadsNilAndIsNotCreated(t *testing.T) { + m := newBoundsManager(t) + + st, err := m.LoadTPABundleState() + require.NoError(t, err) + assert.Nil(t, st) + + hist, err := m.ListTPABundleHistory() + require.NoError(t, err) + assert.Empty(t, hist) + + assert.False(t, tpaBucketExists(t, m), "a read must never create the bucket") +} + +func TestTPABundleState_RoundTripEveryField(t *testing.T) { + m := newBoundsManager(t) + want := tpaFullState() + + require.NoError(t, m.CommitTPABundleState(want, nil)) + + got, err := m.LoadTPABundleState() + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, want, got) + + hist, err := m.ListTPABundleHistory() + require.NoError(t, err) + assert.Empty(t, hist, "a nil entry appends nothing") +} + +func TestTPABundleState_ZeroSchemaVersionIsStamped(t *testing.T) { + m := newBoundsManager(t) + require.NoError(t, m.CommitTPABundleState(&scanner.TPABundleState{SignatureRatchet: true}, nil)) + + got, err := m.LoadTPABundleState() + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, scanner.TPABundleStateSchemaVersion, got.SchemaVersion) +} + +func TestTPABundleState_CommitWithEntryAppendsHistory(t *testing.T) { + m := newBoundsManager(t) + + require.NoError(t, m.CommitTPABundleState(tpaFullState(), tpaEntry(1))) + require.NoError(t, m.CommitTPABundleState(tpaFullState(), tpaEntry(2))) + + hist, err := m.ListTPABundleHistory() + require.NoError(t, err) + require.Len(t, hist, 2) + assert.Equal(t, *tpaEntry(1), hist[0]) + assert.Equal(t, *tpaEntry(2), hist[1]) +} + +// SC-010 replay: N activations survive a close and reopen of the same data +// directory, oldest first, field for field. +func TestTPABundleState_HistoryOrderedAcrossReopen(t *testing.T) { + dir := t.TempDir() + m, err := NewManager(dir, zap.NewNop().Sugar()) + require.NoError(t, err) + + const n = 5 + for i := 1; i <= n; i++ { + require.NoError(t, m.CommitTPABundleState(tpaFullState(), tpaEntry(i))) + } + require.NoError(t, m.Close()) + + m, err = NewManager(dir, zap.NewNop().Sugar()) + require.NoError(t, err) + t.Cleanup(func() { _ = m.Close() }) + + // A further append after reopen continues the sequence rather than + // overwriting an earlier row. + require.NoError(t, m.CommitTPABundleState(tpaFullState(), tpaEntry(n+1))) + + hist, err := m.ListTPABundleHistory() + require.NoError(t, err) + require.Len(t, hist, n+1) + for i, h := range hist { + assert.Equal(t, *tpaEntry(i + 1), h, "row %d", i) + } +} + +// FR-011a: state and history row commit atomically. An entry that cannot be +// marshalled (time.Time rejects years outside 0..9999) must leave both the +// previous state and the history untouched. +func TestTPABundleState_FailedEntryMarshalWritesNeither(t *testing.T) { + m := newBoundsManager(t) + + prev := tpaFullState() + require.NoError(t, m.CommitTPABundleState(prev, tpaEntry(1))) + + next := tpaFullState() + next.Active.Sequence = 99 + bad := tpaEntry(2) + bad.ActivatedAt = time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC) + + require.Error(t, m.CommitTPABundleState(next, bad)) + + got, err := m.LoadTPABundleState() + require.NoError(t, err) + assert.Equal(t, prev, got, "state must be unchanged") + + hist, err := m.ListTPABundleHistory() + require.NoError(t, err) + assert.Equal(t, []scanner.TPABundleActivation{*tpaEntry(1)}, hist, "history must be unchanged") +} + +func TestTPABundleState_FailedFirstCommitCreatesNothing(t *testing.T) { + m := newBoundsManager(t) + bad := tpaEntry(1) + bad.ActivatedAt = time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC) + + require.Error(t, m.CommitTPABundleState(tpaFullState(), bad)) + + st, err := m.LoadTPABundleState() + require.NoError(t, err) + assert.Nil(t, st) + assert.False(t, tpaBucketExists(t, m), "marshal runs before the bucket is touched") +} + +func TestTPABundleState_CorruptStateReturnsError(t *testing.T) { + m := newBoundsManager(t) + require.NoError(t, m.CommitTPABundleState(tpaFullState(), nil)) + + require.NoError(t, m.GetDB().Update(func(tx *bbolt.Tx) error { + return tx.Bucket([]byte(TPABundleStateBucket)).Put([]byte("state"), []byte("{not json")) + })) + + st, err := m.LoadTPABundleState() + require.Error(t, err, "callers fail safe on a corrupt record; never a silent nil") + assert.Nil(t, st) +} + +func TestTPABundleState_CorruptHistoryRowReturnsError(t *testing.T) { + m := newBoundsManager(t) + require.NoError(t, m.CommitTPABundleState(tpaFullState(), tpaEntry(1))) + + require.NoError(t, m.GetDB().Update(func(tx *bbolt.Tx) error { + h := tx.Bucket([]byte(TPABundleStateBucket)).Bucket([]byte("history")) + return h.Put([]byte{0xff, 0, 0, 0, 0, 0, 0, 0}, []byte("garbage")) + })) + + _, err := m.ListTPABundleHistory() + require.Error(t, err) +} + +// The state is installation-wide and keyed by authority: removing a server +// through the purge path must leave it intact. +func TestTPABundleState_ServerRemovalLeavesBucketIntact(t *testing.T) { + m := newBoundsManager(t) + want := tpaFullState() + require.NoError(t, m.CommitTPABundleState(want, tpaEntry(1))) + + cfg := &config.ServerConfig{Name: "srv", URL: "http://srv", Protocol: "http", Enabled: true} + require.NoError(t, m.SaveUpstreamServer(cfg)) + _, err := m.RegisterServerIdentity(cfg, "/tmp/mcp_config.json") + require.NoError(t, err) + require.NoError(t, m.DeleteUpstreamServer("srv")) + + got, err := m.LoadTPABundleState() + require.NoError(t, err) + assert.Equal(t, want, got) + hist, err := m.ListTPABundleHistory() + require.NoError(t, err) + assert.Len(t, hist, 1) +} From 32974977bf663807b2cffca0c81f98d8c681dec8 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 13:02:59 +0300 Subject: [PATCH 04/35] docs(config): clarify tpa_operator_baseline seeds the operator watermark Related #938 --- internal/config/config.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 1a94f5867..54c447d09 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3236,10 +3236,11 @@ type SecurityConfig struct { // lowercase-hex SHA-256 of those bytes. Malformed or duplicate entries are // rejected on write and dropped (with a log) on load. TPAOperatorSigners []string `json:"tpa_operator_signers,omitempty" mapstructure:"tpa-operator-signers"` - // TPAOperatorBaseline is the lowest operator-authority sequence this install - // will ever accept (FR-009b). It survives a data-directory wipe because it - // lives here. Absent means the operator authority has no anti-downgrade - // floor and an active operator bundle runs DEGRADED. + // TPAOperatorBaseline seeds the operator authority's anti-downgrade + // watermark (FR-009b): an operator-signed bundle must carry a sequence + // ABOVE it (a rollback target may sit at it). It survives a data-directory + // wipe because it lives here. Absent means the operator authority has no + // anti-downgrade floor and an active operator bundle runs DEGRADED. TPAOperatorBaseline *uint64 `json:"tpa_operator_baseline,omitempty" mapstructure:"tpa-operator-baseline"` // TPAOperatorEpoch is the operator authority's trust epoch (FR-005a). // Bumping it (after an operator key compromise) re-baselines the operator From aa6ffe424d3fc121331fd8bb4ca0a6fd2c75c98c Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 13:04:27 +0300 Subject: [PATCH 05/35] feat(security): Spec 101 US1 telemetry for the active TPA bundle Schema v15: four typed bundle_* keys inside tpa_scanner (source, signature_verified, schema-line version, sequence), the TPABundleSnapshot provider seam merged into the heartbeat even with zero scan counters, and a whole-tuple rule in the anonymity backstop (T050, T051). Related #938 --- internal/telemetry/anonymity.go | 102 ++++++ .../telemetry/current_error_codes_test.go | 2 +- internal/telemetry/payload_privacy_test.go | 2 +- internal/telemetry/payload_v2_test.go | 6 +- internal/telemetry/payload_v7_test.go | 12 +- internal/telemetry/telemetry.go | 68 +++- internal/telemetry/telemetry_test.go | 8 +- internal/telemetry/tpa_bundle.go | 34 ++ internal/telemetry/tpa_bundle_test.go | 290 ++++++++++++++++++ internal/telemetry/tpa_funnel_v9_test.go | 2 +- internal/telemetry/tpa_scanner.go | 17 + internal/telemetry/tpa_scanner_test.go | 2 +- 12 files changed, 527 insertions(+), 18 deletions(-) create mode 100644 internal/telemetry/tpa_bundle.go create mode 100644 internal/telemetry/tpa_bundle_test.go diff --git a/internal/telemetry/anonymity.go b/internal/telemetry/anonymity.go index 21daf5a7f..7486ae55b 100644 --- a/internal/telemetry/anonymity.go +++ b/internal/telemetry/anonymity.go @@ -7,6 +7,8 @@ import ( "fmt" "os" "path/filepath" + "regexp" + "slices" "sync" ) @@ -246,6 +248,99 @@ var tpaScannerScalarKeys = []string{ "tool_change_gate_scans", "prompt_scans", } +// tpaBundleKeys is the schema-v15 (Spec 101 FR-021) active-bundle group inside +// tpa_scanner. It is deliberately NOT part of tpaScannerScalarKeys, which stays +// integer-only: these four keys have four different types and are validated as +// one tuple by scanTPABundle. +var tpaBundleKeys = []string{ + "bundle_source", "bundle_signature_verified", "bundle_version", "bundle_sequence", +} + +// tpaBundleSources is the closed bundle_source enum. +var tpaBundleSources = []string{"embedded", "file", "fetch"} + +// tpaBundleSchemaLine is the only non-"other" bundle_version shape: a corpus +// schema line such as "0.1". It is a normalization that does not depend on any +// individual release, so a binary can report a corpus line published after it +// shipped, while the near-unique release string never leaves the install +// (research D7). +var tpaBundleSchemaLine = regexp.MustCompile(`^[0-9]{1,3}\.[0-9]{1,3}$`) + +// tpaBundleMaxSequence is 2^53-1, the largest integer every JSON consumer +// represents exactly. +const tpaBundleMaxSequence = 1<<53 - 1 + +// scanTPABundle enforces the typed shape of each bundle key and the WHOLE-TUPLE +// rule: if any bundle_* key is present, bundle_source, bundle_signature_verified +// and bundle_version must all be present; a schema-line bundle_version requires +// bundle_sequence AND (source "embedded", or source file/fetch with +// signature_verified true); bundle_version "other" forbids bundle_sequence. +// +// This is a backstop, so it does not trust the producer's provenance gate: +// every other combination is refused. Violation messages are constants and +// never echo the offending key or value. +func scanTPABundle(obj map[string]json.RawMessage) *AnonymityViolation { + rawSource, hasSource := obj["bundle_source"] + rawVerified, hasVerified := obj["bundle_signature_verified"] + rawVersion, hasVersion := obj["bundle_version"] + rawSeq, hasSeq := obj["bundle_sequence"] + if !hasSource && !hasVerified && !hasVersion && !hasSeq { + return nil + } + if !hasSource || !hasVerified || !hasVersion { + return v8FieldViolation("tpa_scanner", "carries an incomplete bundle tuple") + } + + var source string + if err := json.Unmarshal(rawSource, &source); err != nil || !slices.Contains(tpaBundleSources, source) { + return v8FieldViolation("tpa_scanner.bundle_source", "must be a value of its fixed enum") + } + + // json.Unmarshal into a bool accepts neither strings nor numbers, but does + // accept null (leaving false); require a bare true/false token. + var verified bool + switch string(bytes.TrimSpace(rawVerified)) { + case "true": + verified = true + case "false": + default: + return v8FieldViolation("tpa_scanner.bundle_signature_verified", "must be a boolean") + } + + var version string + if err := json.Unmarshal(rawVersion, &version); err != nil { + return v8FieldViolation("tpa_scanner.bundle_version", "must be a schema line or other") + } + schemaLine := tpaBundleSchemaLine.MatchString(version) + if !schemaLine && version != "other" { + return v8FieldViolation("tpa_scanner.bundle_version", "must be a schema line or other") + } + + if !hasSeq { + if schemaLine { + return v8FieldViolation("tpa_scanner.bundle_sequence", "is required with a schema-line bundle_version") + } + return nil + } + if !schemaLine { + return v8FieldViolation("tpa_scanner.bundle_sequence", "must be absent when bundle_version is other") + } + if source != "embedded" && !verified { + return v8FieldViolation("tpa_scanner.bundle_version", "reports a schema line for an unverified external bundle") + } + msg := json.RawMessage(rawSeq) + if viol := scanNonNegativeInt(&msg, "tpa_scanner.bundle_sequence", v8FieldViolation); viol != nil { + return viol + } + // scanNonNegativeInt already proved a bare, whole, non-negative int64. + var n json.Number + _ = json.Unmarshal(bytes.TrimSpace(rawSeq), &n) + if i, err := n.Int64(); err != nil || i > tpaBundleMaxSequence { + return v8FieldViolation("tpa_scanner.bundle_sequence", "exceeds the exactly-representable integer range") + } + return nil +} + // scanV8TPAScanner asserts the schema-v8 tpa_scanner sub-object (if present) // carries counts and fixed enum keys ONLY: an object whose keys are // whitelisted, whose scalars are non-negative integers, and whose findings map @@ -270,6 +365,9 @@ func scanV8TPAScanner(raw json.RawMessage) *AnonymityViolation { allowed[k] = struct{}{} } allowed["findings"] = struct{}{} + for _, k := range tpaBundleKeys { + allowed[k] = struct{}{} + } for k := range obj { if _, ok := allowed[k]; !ok { // The violation is logged on send failure — echoing the @@ -290,6 +388,10 @@ func scanV8TPAScanner(raw json.RawMessage) *AnonymityViolation { } } + if viol := scanTPABundle(obj); viol != nil { + return viol + } + rawFindings, ok := obj["findings"] if !ok { return nil diff --git a/internal/telemetry/current_error_codes_test.go b/internal/telemetry/current_error_codes_test.go index 7e687509a..13665783f 100644 --- a/internal/telemetry/current_error_codes_test.go +++ b/internal/telemetry/current_error_codes_test.go @@ -265,7 +265,7 @@ func TestPayloadV11_CurrentErrorCodesReachTheWire(t *testing.T) { js := string(data) for _, want := range []string{ - `"schema_version":14`, + `"schema_version":15`, `"MCPX_OAUTH_LOGIN_REQUIRED":2`, `"MCPX_HTTP_DNS_FAILED":1`, `"current_error_codes"`, diff --git a/internal/telemetry/payload_privacy_test.go b/internal/telemetry/payload_privacy_test.go index 59b4f485b..66cca896a 100644 --- a/internal/telemetry/payload_privacy_test.go +++ b/internal/telemetry/payload_privacy_test.go @@ -144,7 +144,7 @@ func TestPayloadHasNoForbiddenSubstrings(t *testing.T) { // Sanity check: the payload should still contain the legitimate fields, // otherwise we've over-redacted. for _, required := range []string{ - `"schema_version":14`, + `"schema_version":15`, `"surface_requests"`, `"builtin_tool_calls"`, `"upstream_tool_call_count_bucket"`, diff --git a/internal/telemetry/payload_v2_test.go b/internal/telemetry/payload_v2_test.go index fa8b26dd6..76b3d0f13 100644 --- a/internal/telemetry/payload_v2_test.go +++ b/internal/telemetry/payload_v2_test.go @@ -88,8 +88,8 @@ func TestHeartbeatPayloadV2Marshal(t *testing.T) { payload := svc.BuildPayload() - if payload.SchemaVersion != 14 { - t.Errorf("schema_version = %d, want 14", payload.SchemaVersion) + if payload.SchemaVersion != 15 { + t.Errorf("schema_version = %d, want 15", payload.SchemaVersion) } if payload.AnonymousID != "fixed-id" { t.Errorf("anonymous_id = %q", payload.AnonymousID) @@ -135,7 +135,7 @@ func TestHeartbeatPayloadV2Marshal(t *testing.T) { } js := string(data) for _, key := range []string{ - `"schema_version":14`, + `"schema_version":15`, `"surface_requests"`, `"builtin_tool_calls"`, `"upstream_tool_call_count_bucket":"11-100"`, diff --git a/internal/telemetry/payload_v7_test.go b/internal/telemetry/payload_v7_test.go index fdc27ba2c..d0e694a4c 100644 --- a/internal/telemetry/payload_v7_test.go +++ b/internal/telemetry/payload_v7_test.go @@ -8,7 +8,7 @@ import ( ) // TestSchemaVersionIsAtLeastV7 pins FR-014: the Spec 080 payload contract -// shipped at v7 and may only move forward. The current version is 14 (v8's +// shipped at v7 and may only move forward. The current version is 15 (v8's // additive tpa_scanner / deep_scan_enabled bump, v9's TPA funnel counters + // trust_mode_distribution, v10's tool_response_mode / // direct_tool_response_mode, then v11's edge-triggered error_code_counts_24h + @@ -17,8 +17,8 @@ func TestSchemaVersionIsAtLeastV7(t *testing.T) { if SchemaVersion < 7 { t.Fatalf("SchemaVersion = %d, want >= 7 (Spec 080 FR-014)", SchemaVersion) } - if SchemaVersion != 14 { - t.Fatalf("SchemaVersion = %d, want 14 (v14 activation/attempt semantics)", SchemaVersion) + if SchemaVersion != 15 { + t.Fatalf("SchemaVersion = %d, want 15 (v15 TPA bundle provenance)", SchemaVersion) } } @@ -78,7 +78,7 @@ func TestPayloadV7_FullyPopulatedPassesScanner(t *testing.T) { } for _, required := range []string{ - `"schema_version":14`, + `"schema_version":15`, `"wizard_shown":true`, `"wizard_connect_step":"completed_external"`, `"web_ui_opened":3`, @@ -109,8 +109,8 @@ func TestPayloadV7_ZeroNewFieldsShapeCompatibleWithV6(t *testing.T) { } js := string(data) - if !strings.Contains(js, `"schema_version":14`) { - t.Errorf("expected schema_version:14 even on a zero-valued payload, got:\n%s", js) + if !strings.Contains(js, `"schema_version":15`) { + t.Errorf("expected schema_version:15 even on a zero-valued payload, got:\n%s", js) } for _, forbidden := range []string{ `"wizard_shown"`, diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 064314d76..4ff8e02cc 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -160,7 +160,23 @@ import ( // before dispatch, and nothing for direct routing or JS sub-calls). v13-and- // earlier values are not comparable with v14 ones; do not graph them as one // series, and do not reinterpret historical flags. -const SchemaVersion = 14 +// +// v15 (Spec 101 FR-021) adds the ACTIVE TPA signature bundle's provenance as +// four typed keys inside the existing tpa_scanner sub-object: bundle_source +// (closed enum embedded|file|fetch), bundle_signature_verified (boolean), +// bundle_version (the literal "other" or a corpus schema line matching +// ^[0-9]{1,3}\.[0-9]{1,3}$, e.g. "0.1" — never the bundle's free-form release +// string) and bundle_sequence (integer <= 2^53-1, present only together with a +// schema-line bundle_version). Unlike the counters beside them these are STATE +// read fresh at every heartbeat build and never cleared by the reset-on-accept +// window, so tpa_scanner is now also emitted when every scan counter is zero +// whenever a bundle provider (SetTPABundleProvider) returns a snapshot. The +// provider applies the provenance gate (schema line + sequence only for the +// embedded default or a publisher-verified bundle, else "other"); the +// anonymity backstop independently enforces the whole tuple. Installs with no +// provider, or whose provider returns nil, send the v14 shape plus the new +// schema_version only. +const SchemaVersion = 15 // HeartbeatPayload is the anonymous telemetry payload sent periodically. // Spec 042 expanded the payload with Tier 2 fields; v1 fields are preserved. @@ -377,6 +393,13 @@ type HeartbeatPayload struct { // scanned server name, scanner id, rule id, or finding title. TPAScanner *TPAScannerStats `json:"tpa_scanner,omitempty"` + // tpaBundle is the Spec 101 FR-021 active-bundle snapshot captured when the + // payload was built. It is NOT serialized itself: applyTPABundle merges it + // into TPAScanner, and it is kept on the payload because + // applyRegistrySnapshot REPLACES TPAScanner with the counter view, so the + // merge has to be re-applied after every counter application. + tpaBundle *TPABundleSnapshot + // Schema v9: count of configured servers per effective trust tier, keyed // exclusively by the fixed auto|scan|manual enum (all three keys always // present, even at zero — the protocol-counts convention). A STATE field, @@ -516,6 +539,11 @@ type Service struct { // onboarding fields are simply omitted from the heartbeat. onboardingProvider func() *OnboardingSnapshot + // Spec 101 FR-021: optional provider for the active TPA bundle's + // provenance tuple (source, signature_verified, schema line, sequence). + // nil-safe: when unset, or when it returns nil, no bundle keys are sent. + tpaBundleProvider func() *TPABundleSnapshot + // MCP-2967: optional provider for the STANDING diagnostic state — // classified MCPX_* code -> number of configured servers currently in // that state. Wired by the runtime with a closure over the supervisor's @@ -1589,6 +1617,18 @@ func (s *Service) buildHeartbeatWithOneShots(consumeOneShots bool) HeartbeatPayl applyRegistrySnapshot(&payload, snap) } + // Spec 101 FR-021: active TPA bundle provenance tuple. Captured on the + // payload and merged into tpa_scanner (also re-merged by + // applyRegistrySnapshot, which replaces that object). The provider owns + // the provenance gate; scanTPABundle re-enforces the tuple on the wire. + if s.tpaBundleProvider != nil { + if snap := s.tpaBundleProvider(); snap != nil { + cp := *snap + payload.tpaBundle = &cp + applyTPABundle(&payload) + } + } + // Spec 046: onboarding funnel snapshot. Provider closes over connect.Service // (for the connected-client count + IDs) and the BBolt-backed onboarding // state (for wizard engagement + per-step status). nil-safe. @@ -1693,6 +1733,32 @@ func applyRegistrySnapshot(payload *HeartbeatPayload, snap RegistrySnapshot) { // Schema v8: security-scanner counters. nil (and therefore omitted) when // the install never completed or failed a scan in the window. payload.TPAScanner = snap.TPAScannerStats() + // Bundle state is per-heartbeat state, not a reset-on-accept counter: the + // counter view above just replaced TPAScanner, so merge it back in. + applyTPABundle(payload) +} + +// applyTPABundle merges the captured active-bundle snapshot into the +// tpa_scanner sub-object, creating the object when every scan counter is zero +// (an install that never scanned still has an active bundle). A payload +// without a snapshot is left untouched. It is idempotent. +func applyTPABundle(payload *HeartbeatPayload) { + b := payload.tpaBundle + if b == nil { + return + } + if payload.TPAScanner == nil { + payload.TPAScanner = &TPAScannerStats{} + } + verified := b.SignatureVerified + payload.TPAScanner.BundleSource = b.Source + payload.TPAScanner.BundleSignatureVerified = &verified + payload.TPAScanner.BundleVersion = b.BundleVersion + payload.TPAScanner.BundleSequence = nil + if b.Sequence != nil { + seq := *b.Sequence + payload.TPAScanner.BundleSequence = &seq + } } // ensureAnonymousID gives the install an anonymous id, generating and diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index b34617f09..5e6b34ef3 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -312,16 +312,16 @@ func TestEnsureAnonymousID(t *testing.T) { // once the Spec 080 funnel/churn fields ship. This is a tripwire against // accidental downgrades. func TestSchemaVersionV7(t *testing.T) { - if SchemaVersion != 14 { - t.Fatalf("SchemaVersion = %d, want 14", SchemaVersion) + if SchemaVersion != 15 { + t.Fatalf("SchemaVersion = %d, want 15", SchemaVersion) } cfg := &config.Config{} svc := New(cfg, "", "v1.0.0", "personal", zap.NewNop()) svc.SetRuntimeStats(&mockRuntimeStats{}) payload := svc.BuildPayload() - if payload.SchemaVersion != 14 { - t.Errorf("payload.SchemaVersion = %d, want 14", payload.SchemaVersion) + if payload.SchemaVersion != 15 { + t.Errorf("payload.SchemaVersion = %d, want 15", payload.SchemaVersion) } } diff --git a/internal/telemetry/tpa_bundle.go b/internal/telemetry/tpa_bundle.go new file mode 100644 index 000000000..276619649 --- /dev/null +++ b/internal/telemetry/tpa_bundle.go @@ -0,0 +1,34 @@ +package telemetry + +// TPABundleSnapshot is the per-heartbeat state of the ACTIVE TPA signature +// bundle (Spec 101 FR-021, research D7). It is STATE, not a counter: it is read +// fresh on every heartbeat build and is never cleared by the reset-on-accept +// counter window. +// +// PROVENANCE GATE: reporting the schema line and the sequence only when the +// active bundle is the embedded default or was signature-verified against a +// PUBLISHER key (and reporting "other" with Sequence nil otherwise, whatever +// version string the bundle claims) is the PROVIDER's job. Only the scanner +// knows which authority verified a bundle; this package must not import it. The +// anonymity backstop (scanTPABundle) independently enforces the whole tuple, so +// a provider bug fails closed (the heartbeat is refused) instead of leaking. +type TPABundleSnapshot struct { + // Source is the closed enum "embedded" | "file" | "fetch". + Source string + // SignatureVerified is whether the active bundle verified against a trusted + // key. It is a plain boolean on the wire. + SignatureVerified bool + // BundleVersion is "other" or a schema line "." (for example + // "0.1"). Never the bundle's free-form release string. + BundleVersion string + // Sequence is the monotonic publisher sequence; non-nil ONLY together with a + // schema-line BundleVersion. + Sequence *uint64 +} + +// SetTPABundleProvider wires a function that returns the active-bundle +// snapshot for the next heartbeat. Each call must return fresh data. Returning +// nil adds nothing to the payload. +func (s *Service) SetTPABundleProvider(fn func() *TPABundleSnapshot) { + s.tpaBundleProvider = fn +} diff --git a/internal/telemetry/tpa_bundle_test.go b/internal/telemetry/tpa_bundle_test.go new file mode 100644 index 000000000..1ca3a07a7 --- /dev/null +++ b/internal/telemetry/tpa_bundle_test.go @@ -0,0 +1,290 @@ +package telemetry + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// bundleObj wraps the given tpa_scanner members into a minimal payload. +func bundleObj(members string) []byte { + return []byte(`{"anonymous_id":"abc","schema_version":15,"tpa_scanner":{` + members + `}}`) +} + +func u64(v uint64) *uint64 { return &v } + +// TestScanForPII_TPABundleTupleAccepted covers every valid tuple of the +// Spec 101 FR-021 bundle keys. +func TestScanForPII_TPABundleTupleAccepted(t *testing.T) { + withoutBlockedValues(t) + clean := []string{ + `"bundle_source":"embedded","bundle_signature_verified":false,"bundle_version":"0.1","bundle_sequence":3`, + `"bundle_source":"embedded","bundle_signature_verified":true,"bundle_version":"12.34","bundle_sequence":0`, + `"bundle_source":"file","bundle_signature_verified":true,"bundle_version":"0.1","bundle_sequence":9007199254740991`, + `"bundle_source":"fetch","bundle_signature_verified":true,"bundle_version":"1.0","bundle_sequence":7`, + `"bundle_source":"file","bundle_signature_verified":false,"bundle_version":"other"`, + `"bundle_source":"fetch","bundle_signature_verified":true,"bundle_version":"other"`, + `"bundle_source":"embedded","bundle_signature_verified":false,"bundle_version":"other"`, + // Together with the existing counters and findings. + `"scans_completed":1,"scans_failed":0,"scans_with_findings":1,"findings":{"high":1},` + + `"bundle_source":"embedded","bundle_signature_verified":false,"bundle_version":"0.1","bundle_sequence":1`, + } + for _, m := range clean { + if err := ScanForPII(bundleObj(m)); err != nil { + t.Errorf("valid tuple rejected: %v\n%s", err, m) + } + } +} + +// TestScanForPII_TPABundleTupleViolations pins the whole-tuple rule and the +// typed-key shapes. Every violation is a v8_field_invalid and never echoes the +// offending key or value. +func TestScanForPII_TPABundleTupleViolations(t *testing.T) { + withoutBlockedValues(t) + const goodTail = `"bundle_signature_verified":true,"bundle_version":"0.1","bundle_sequence":5` + dirty := []struct{ name, members string }{ + {"free-string bundle_version", + `"bundle_source":"embedded","bundle_signature_verified":false,"bundle_version":"0.1.7-acme","bundle_sequence":1`}, + {"three-part version", + `"bundle_source":"embedded","bundle_signature_verified":false,"bundle_version":"0.1.7","bundle_sequence":1`}, + {"four-digit schema line", + `"bundle_source":"embedded","bundle_signature_verified":false,"bundle_version":"1000.1","bundle_sequence":1`}, + {"empty bundle_version", + `"bundle_source":"embedded","bundle_signature_verified":false,"bundle_version":"","bundle_sequence":1`}, + {"non-string bundle_version", + `"bundle_source":"embedded","bundle_signature_verified":false,"bundle_version":0.1,"bundle_sequence":1`}, + {"sequence with other", + `"bundle_source":"file","bundle_signature_verified":true,"bundle_version":"other","bundle_sequence":4`}, + {"schema line without sequence", + `"bundle_source":"embedded","bundle_signature_verified":false,"bundle_version":"0.1"`}, + {"file unverified schema line", + `"bundle_source":"file","bundle_signature_verified":false,"bundle_version":"0.1","bundle_sequence":2`}, + {"fetch unverified schema line", + `"bundle_source":"fetch","bundle_signature_verified":false,"bundle_version":"0.1","bundle_sequence":2`}, + {"unknown source", + `"bundle_source":"usb",` + goodTail}, + {"empty source", + `"bundle_source":"",` + goodTail}, + {"non-string source", + `"bundle_source":1,` + goodTail}, + {"non-bool signature_verified", + `"bundle_source":"file","bundle_signature_verified":"true","bundle_version":"0.1","bundle_sequence":5`}, + {"numeric signature_verified", + `"bundle_source":"file","bundle_signature_verified":1,"bundle_version":"0.1","bundle_sequence":5`}, + {"sequence 2^53", + `"bundle_source":"embedded","bundle_signature_verified":false,"bundle_version":"0.1","bundle_sequence":9007199254740992`}, + {"negative sequence", + `"bundle_source":"embedded","bundle_signature_verified":false,"bundle_version":"0.1","bundle_sequence":-1`}, + {"float sequence", + `"bundle_source":"embedded","bundle_signature_verified":false,"bundle_version":"0.1","bundle_sequence":1.5`}, + {"string sequence", + `"bundle_source":"embedded","bundle_signature_verified":false,"bundle_version":"0.1","bundle_sequence":"5"`}, + {"version without source", + `"bundle_signature_verified":false,"bundle_version":"other"`}, + {"source without version", + `"bundle_source":"embedded","bundle_signature_verified":false`}, + {"source without signature_verified", + `"bundle_source":"embedded","bundle_version":"other"`}, + {"sequence alone", + `"bundle_sequence":5`}, + {"null source", + `"bundle_source":null,` + goodTail}, + } + for _, tc := range dirty { + err := ScanForPII(bundleObj(tc.members)) + if err == nil { + t.Errorf("%s: expected a violation for %s", tc.name, tc.members) + continue + } + var v *AnonymityViolation + if !errors.As(err, &v) { + t.Errorf("%s: expected *AnonymityViolation, got %T", tc.name, err) + continue + } + if v.Rule != "v8_field_invalid" { + t.Errorf("%s: rule = %q, want v8_field_invalid", tc.name, v.Rule) + } + for _, secret := range []string{"0.1.7", "acme", "usb", "9007199254740992", "1000.1"} { + if strings.Contains(v.Pattern, secret) || strings.Contains(v.Reason, secret) { + t.Errorf("%s: violation echoed %q: pattern=%q reason=%q", tc.name, secret, v.Pattern, v.Reason) + } + } + } +} + +// TestScanForPII_TPABundleUnknownKeyStillRejected: the bundle group widens the +// whitelist by exactly four keys. +func TestScanForPII_TPABundleUnknownKeyStillRejected(t *testing.T) { + withoutBlockedValues(t) + err := ScanForPII(bundleObj(`"bundle_source":"embedded","bundle_signature_verified":false,"bundle_version":"other","bundle_path":"/x"`)) + if err == nil { + t.Fatal("unknown bundle_* key must be rejected") + } +} + +func newBundleTestService(t *testing.T) *Service { + t.Helper() + return newTPAPayloadTestService(t, false) +} + +// TestTPABundleProvider_ZeroCountersStillCarriesObject: bundle state is +// per-heartbeat state, so tpa_scanner appears even when no scan ran. +func TestTPABundleProvider_ZeroCountersStillCarriesObject(t *testing.T) { + svc := newBundleTestService(t) + svc.SetTPABundleProvider(func() *TPABundleSnapshot { + return &TPABundleSnapshot{Source: "embedded", SignatureVerified: false, BundleVersion: "0.1", Sequence: u64(42)} + }) + + payload := svc.BuildPayload() + if payload.TPAScanner == nil { + t.Fatal("tpa_scanner = nil, want an object carrying the bundle keys") + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + js := string(data) + for _, want := range []string{ + `"schema_version":15`, + `"tpa_scanner":{`, + `"scans_completed":0`, + `"bundle_source":"embedded"`, + `"bundle_signature_verified":false`, + `"bundle_version":"0.1"`, + `"bundle_sequence":42`, + } { + if !strings.Contains(js, want) { + t.Errorf("payload missing %s:\n%s", want, js) + } + } + withoutBlockedValues(t) + if err := ScanForPII(data); err != nil { + t.Fatalf("payload must pass the backstop: %v\n%s", err, js) + } +} + +// TestTPABundleProvider_OtherOmitsSequence: the "other" form carries no +// sequence key and a verified=false boolean is still transmitted (not +// omitted). +func TestTPABundleProvider_OtherOmitsSequence(t *testing.T) { + svc := newBundleTestService(t) + svc.SetTPABundleProvider(func() *TPABundleSnapshot { + return &TPABundleSnapshot{Source: "file", SignatureVerified: false, BundleVersion: "other"} + }) + data, err := json.Marshal(svc.BuildPayload()) + if err != nil { + t.Fatal(err) + } + js := string(data) + if strings.Contains(js, "bundle_sequence") { + t.Errorf("bundle_sequence must be absent for other:\n%s", js) + } + if !strings.Contains(js, `"bundle_signature_verified":false`) { + t.Errorf("bundle_signature_verified:false must be transmitted:\n%s", js) + } + withoutBlockedValues(t) + if err := ScanForPII(data); err != nil { + t.Fatalf("backstop: %v\n%s", err, js) + } +} + +// TestTPABundleProvider_MergesWithCountersAndSurvivesPendingSnapshot: the +// merge must survive applyRegistrySnapshot (which replaces TPAScanner) on the +// pending-snapshot path, and must not be cleared by the counter reset. +func TestTPABundleProvider_MergesWithCountersAndSurvivesPendingSnapshot(t *testing.T) { + svc := newBundleTestService(t) + svc.SetTPABundleProvider(func() *TPABundleSnapshot { + return &TPABundleSnapshot{Source: "fetch", SignatureVerified: true, BundleVersion: "0.1", Sequence: u64(7)} + }) + svc.Registry().RecordTPAScanCompleted(map[string]int{"high": 1}) + + // Pending snapshot path (BuildPayload applies it after the build). + snap := svc.Registry().Drain() + svc.pendingCounterSnapshot = &snap + payload := svc.BuildPayload() + if payload.TPAScanner == nil || payload.TPAScanner.ScansCompleted != 1 { + t.Fatalf("counters lost: %+v", payload.TPAScanner) + } + if payload.TPAScanner.BundleSource != "fetch" || payload.TPAScanner.BundleSequence == nil || *payload.TPAScanner.BundleSequence != 7 { + t.Fatalf("bundle keys lost after applyRegistrySnapshot: %+v", payload.TPAScanner) + } + + // After the accepted-send reset the next heartbeat still reports the bundle. + svc.pendingCounterSnapshot = nil + payload = svc.BuildPayload() + if payload.TPAScanner == nil || payload.TPAScanner.BundleSource != "fetch" { + t.Fatalf("bundle state cleared by counter reset: %+v", payload.TPAScanner) + } + if payload.TPAScanner.ScansCompleted != 0 { + t.Errorf("scans_completed = %d, want 0 after drain", payload.TPAScanner.ScansCompleted) + } +} + +// TestTPABundleProvider_NilAddsNothing: a provider returning nil leaves the +// payload exactly as before. +func TestTPABundleProvider_NilAddsNothing(t *testing.T) { + svc := newBundleTestService(t) + svc.SetTPABundleProvider(func() *TPABundleSnapshot { return nil }) + payload := svc.BuildPayload() + if payload.TPAScanner != nil { + t.Errorf("tpa_scanner = %+v, want nil", payload.TPAScanner) + } + data, _ := json.Marshal(payload) + if strings.Contains(string(data), "bundle_") || strings.Contains(string(data), "tpa_scanner") { + t.Errorf("bundle keys or tpa_scanner appeared with a nil snapshot:\n%s", data) + } + + // With scan counters and a nil snapshot: exactly the old object. + svc.Registry().RecordTPAScanFailed() + payload = svc.BuildPayload() + if payload.TPAScanner == nil || payload.TPAScanner.BundleSource != "" || payload.TPAScanner.BundleSequence != nil { + t.Errorf("unexpected bundle fields: %+v", payload.TPAScanner) + } + data, _ = json.Marshal(payload) + if strings.Contains(string(data), "bundle_") { + t.Errorf("bundle keys leaked with a nil snapshot:\n%s", data) + } +} + +// TestTPABundleProvider_NotConsultedAfterOptOut: the existing opt-out latch +// returns from sendHeartbeat before any payload is built, so the provider is +// never called and nothing is transmitted. +func TestTPABundleProvider_NotConsultedAfterOptOut(t *testing.T) { + clearTelemetryEnv(t) + + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + cfg := &config.Config{Telemetry: &config.TelemetryConfig{ + Enabled: boolPtr(true), AnonymousID: "anon-xyz", Endpoint: server.URL, + }} + svc := New(cfg, "", "v1.2.3", "personal", zap.NewNop()) + var calls atomic.Int32 + svc.SetTPABundleProvider(func() *TPABundleSnapshot { + calls.Add(1) + return &TPABundleSnapshot{Source: "embedded", BundleVersion: "0.1", Sequence: u64(1)} + }) + svc.optedOut.Store(true) + + svc.sendHeartbeat(context.Background()) + + if calls.Load() != 0 { + t.Errorf("bundle provider consulted %d times after opt-out", calls.Load()) + } + if hits.Load() != 0 { + t.Errorf("heartbeat transmitted after opt-out: %d", hits.Load()) + } +} diff --git a/internal/telemetry/tpa_funnel_v9_test.go b/internal/telemetry/tpa_funnel_v9_test.go index c74373c5c..07b155423 100644 --- a/internal/telemetry/tpa_funnel_v9_test.go +++ b/internal/telemetry/tpa_funnel_v9_test.go @@ -260,7 +260,7 @@ func TestPayloadV9_TrustModeDistributionAndFunnelCounters(t *testing.T) { } js := string(data) for _, required := range []string{ - `"schema_version":14`, + `"schema_version":15`, `"trust_mode_distribution":`, `"tool_change_gate_scans":2`, `"prompt_scans":1`, diff --git a/internal/telemetry/tpa_scanner.go b/internal/telemetry/tpa_scanner.go index 84bd87504..7f1a340a5 100644 --- a/internal/telemetry/tpa_scanner.go +++ b/internal/telemetry/tpa_scanner.go @@ -74,6 +74,20 @@ type TPAScannerStats struct { // refresh). Same posture as ToolChangeGateScans: invocations only, never // the prompt name, the server, or the verdict. PromptScans int64 `json:"prompt_scans"` + + // Schema v15 (Spec 101 FR-021): the ACTIVE bundle's provenance tuple. This + // is per-heartbeat STATE merged from the TPABundleSnapshot provider after + // the counters are applied; it is not a reset-on-accept counter. Typed and + // kept in its own group so tpaScannerScalarKeys stays integer-only. The + // backstop (scanTPABundle) enforces the whole tuple. + BundleSource string `json:"bundle_source,omitempty"` + // BundleSignatureVerified is a pointer so an explicit false is transmitted + // while a heartbeat without a bundle snapshot omits the key. + BundleSignatureVerified *bool `json:"bundle_signature_verified,omitempty"` + // BundleVersion is "other" or a "." schema line. + BundleVersion string `json:"bundle_version,omitempty"` + // BundleSequence is present only together with a schema-line BundleVersion. + BundleSequence *uint64 `json:"bundle_sequence,omitempty"` } // isZero reports whether nothing at all was recorded, in which case the @@ -88,6 +102,9 @@ func (t *TPAScannerStats) isZero() bool { if t.ToolChangeGateScans != 0 || t.PromptScans != 0 { return false } + if t.BundleSource != "" || t.BundleSignatureVerified != nil || t.BundleVersion != "" || t.BundleSequence != nil { + return false + } for _, n := range t.Findings { if n != 0 { return false diff --git a/internal/telemetry/tpa_scanner_test.go b/internal/telemetry/tpa_scanner_test.go index 7eb6ed119..b209e2a1c 100644 --- a/internal/telemetry/tpa_scanner_test.go +++ b/internal/telemetry/tpa_scanner_test.go @@ -264,7 +264,7 @@ func TestPayloadV8_TPAScannerIncludedAndAnonymous(t *testing.T) { js := string(data) for _, required := range []string{ - `"schema_version":14`, + `"schema_version":15`, `"tpa_scanner":`, `"scans_completed":2`, `"scans_failed":1`, From 28f9d3c23703b1a196978db8686b7d0823d5c6b3 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 13:05:22 +0300 Subject: [PATCH 06/35] feat(security): Spec 101 US1 descriptor-based time-bounded candidate open Adds readCandidateFile for the TPA bundle loader: no-follow open, fstat on the descriptor, size ceiling checked on the descriptor and re-bounded while reading, goroutine read bounded by ctx, and a one-parked-reader-per-path cap. Covers T020, T021 (FR-011, SC-011). Related #938 --- internal/security/scanner/candidate_open.go | 199 ++++++++++++++++++ .../security/scanner/candidate_open_other.go | 32 +++ .../security/scanner/candidate_open_test.go | 178 ++++++++++++++++ .../security/scanner/candidate_open_unix.go | 77 +++++++ .../scanner/candidate_open_unix_test.go | 98 +++++++++ .../scanner/candidate_open_windows.go | 59 ++++++ .../scanner/candidate_open_windows_test.go | 22 ++ 7 files changed, 665 insertions(+) create mode 100644 internal/security/scanner/candidate_open.go create mode 100644 internal/security/scanner/candidate_open_other.go create mode 100644 internal/security/scanner/candidate_open_test.go create mode 100644 internal/security/scanner/candidate_open_unix.go create mode 100644 internal/security/scanner/candidate_open_unix_test.go create mode 100644 internal/security/scanner/candidate_open_windows.go create mode 100644 internal/security/scanner/candidate_open_windows_test.go diff --git a/internal/security/scanner/candidate_open.go b/internal/security/scanner/candidate_open.go new file mode 100644 index 000000000..7fc16a755 --- /dev/null +++ b/internal/security/scanner/candidate_open.go @@ -0,0 +1,199 @@ +package scanner + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "math" + "os" + "sync" +) + +// candidateReadError is the classified failure of readCandidateFile. Reason is +// one of RejectNotRegularFile, RejectReadTimeout, RejectReadError or +// RejectCeilingExceeded (tpa_bundle_reasons.go); the loader branches on it and +// copies Detail into BundleInfo.LastRejection.Detail. +// +// NotExist is true ONLY when the open itself failed because the file is absent. +// The loader treats an absent sidecar as "unsigned" (tasks.md A7) but every +// other failure -- including a present-but-unreadable sidecar -- as a +// rejection, so the distinction must not be inferred from Reason alone. +type candidateReadError struct { + Reason string + Detail string + NotExist bool + Err error +} + +func (e *candidateReadError) Error() string { + if e.Detail == "" { + return e.Reason + } + return e.Reason + ": " + e.Detail +} + +func (e *candidateReadError) Unwrap() error { return e.Err } + +// Test-only seams. Production code leaves both nil. +var ( + // candidateOpenHook, when non-nil, is called with the path immediately + // before the open syscall (after path resolution), so a test can swap the + // path for a FIFO or symlink in the check-to-open window (SC-011a). + candidateOpenHook func(path string) + // candidateReaderWrap, when non-nil, wraps the bounded reader of the open + // descriptor, so a test can inject a reader that blocks like a stalled + // network filesystem. + candidateReaderWrap func(io.Reader) io.Reader +) + +// candidateParked records the paths that still have an abandoned reader +// goroutine parked in a blocked read. A path stays in the set until that +// goroutine's read returns, and while it is there readCandidateFile refuses to +// open the path again: on a stalled network filesystem every refresh would +// otherwise strand one more goroutine and file descriptor. +var candidateParked = struct { + mu sync.Mutex + paths map[string]struct{} +}{paths: map[string]struct{}{}} + +// candidateResult is what the reader goroutine hands back. +type candidateResult struct { + data []byte + err error +} + +// readCandidateFile reads a TPA bundle candidate (or its sidecar) of at most +// limit bytes from path, descriptor-first and time-bounded (FR-011, SC-011, +// research D5). +// +// Why descriptor-first: every property is decided on the descriptor the open +// returned, never on the path. A path check (Lstat) followed by an open is a +// window in which an attacker-controlled directory can swap a regular file for +// a FIFO, which would park the caller forever, or for a symlink to a file the +// process should not read. The platform open refuses a symlink at the final +// component atomically and does not block on a FIFO; the shared code then +// fstat's the descriptor, refuses anything that is not a regular file, checks +// the descriptor's size against limit, and reads at most limit+1 bytes from +// that same descriptor (a file that grew after the fstat is still bounded). A +// file of exactly limit bytes loads. +// +// Why a goroutine and not a read deadline: SetReadDeadline does not apply to +// regular files, and the stall this guards against is exactly a regular file +// on a hung network mount. The read therefore runs in a goroutine and the +// caller waits on ctx; when ctx expires first the caller returns +// RejectReadTimeout immediately and the goroutine, which owns the descriptor, +// closes it whenever its read finally returns. While such an abandoned reader +// is parked the path is refused fast (see candidateParked). +func readCandidateFile(ctx context.Context, path string, limit int64) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, &candidateReadError{Reason: RejectReadTimeout, Detail: "read budget expired before open: " + err.Error(), Err: err} + } + + candidateParked.mu.Lock() + if _, parked := candidateParked.paths[path]; parked { + candidateParked.mu.Unlock() + return nil, &candidateReadError{Reason: RejectReadTimeout, Detail: "previous read still blocked"} + } + candidateParked.mu.Unlock() + + // Capture the seams here so the goroutine never reads package state a test + // may reset after this call has returned. + hook, wrap := candidateOpenHook, candidateReaderWrap + + var ( + mu sync.Mutex + done bool + abandoned bool + ) + resCh := make(chan candidateResult, 1) + go func() { + data, err := loadCandidate(path, limit, hook, wrap) + mu.Lock() + done = true + wasAbandoned := abandoned + mu.Unlock() + if wasAbandoned { + candidateParked.mu.Lock() + delete(candidateParked.paths, path) + candidateParked.mu.Unlock() + return + } + resCh <- candidateResult{data: data, err: err} + }() + + select { + case r := <-resCh: + return r.data, r.err + case <-ctx.Done(): + mu.Lock() + if done { + // The goroutine finished just as the budget expired; its result is + // in the channel (it does not send when abandoned, but it was not). + mu.Unlock() + r := <-resCh + return r.data, r.err + } + abandoned = true + candidateParked.mu.Lock() + candidateParked.paths[path] = struct{}{} + candidateParked.mu.Unlock() + mu.Unlock() + return nil, &candidateReadError{Reason: RejectReadTimeout, Detail: fmt.Sprintf("read budget expired: %v", ctx.Err()), Err: ctx.Err()} + } +} + +// loadCandidate is the body of the reader goroutine: open, classify the +// descriptor, size-check it, read it bounded. It owns and always closes the +// descriptor. +func loadCandidate(path string, limit int64, hook func(string), wrap func(io.Reader) io.Reader) ([]byte, error) { + if hook != nil { + hook(path) + } + f, size, err := openCandidateDescriptor(path) + if err != nil { + var ce *candidateReadError + if errors.As(err, &ce) { + return nil, ce + } + return nil, &candidateReadError{ + Reason: RejectReadError, + Detail: "open: " + err.Error(), + NotExist: errors.Is(err, fs.ErrNotExist), + Err: err, + } + } + defer f.Close() + + if size > limit { + return nil, &candidateReadError{Reason: RejectCeilingExceeded, Detail: fmt.Sprintf("limit %d bytes, observed %d bytes", limit, size)} + } + + // Read at most limit+1 bytes from the SAME descriptor: one extra byte is + // enough to tell "grew past the limit" from "exactly at the limit". + readMax := limit + if readMax < math.MaxInt64 { + readMax++ + } + r := io.LimitReader(f, readMax) + if wrap != nil { + r = wrap(r) + } + data, err := io.ReadAll(r) + if err != nil { + return nil, &candidateReadError{Reason: RejectReadError, Detail: "read: " + err.Error(), Err: err} + } + if int64(len(data)) > limit { + return nil, &candidateReadError{Reason: RejectCeilingExceeded, Detail: fmt.Sprintf("limit %d bytes, observed more than %d bytes while reading", limit, limit)} + } + return data, nil +} + +// notRegularError builds the refusal for a candidate that is not a plain file. +func notRegularError(what string) *candidateReadError { + return &candidateReadError{Reason: RejectNotRegularFile, Detail: what} +} + +// Compile-time guard that the platform files all provide the same signature. +var _ func(string) (*os.File, int64, error) = openCandidateDescriptor diff --git a/internal/security/scanner/candidate_open_other.go b/internal/security/scanner/candidate_open_other.go new file mode 100644 index 000000000..ff7a81f69 --- /dev/null +++ b/internal/security/scanner/candidate_open_other.go @@ -0,0 +1,32 @@ +//go:build !unix && !windows + +package scanner + +import ( + "os" +) + +// openCandidateDescriptor is the fallback for targets with neither O_NOFOLLOW +// nor reparse-point handles (plan9, js/wasm). It cannot be atomic, so it +// narrows the window as far as it can: Lstat refuses a symlink up front, the +// file is opened, and the type is then re-checked on the descriptor. These +// targets are not shipped; the stub exists so the package builds everywhere. +func openCandidateDescriptor(path string) (*os.File, int64, error) { + if fi, err := os.Lstat(path); err == nil && !fi.Mode().IsRegular() { + return nil, 0, notRegularError("not a regular file: " + fi.Mode().String()) + } + f, err := os.Open(path) + if err != nil { + return nil, 0, err + } + fi, err := f.Stat() + if err != nil { + _ = f.Close() + return nil, 0, err + } + if !fi.Mode().IsRegular() { + _ = f.Close() + return nil, 0, notRegularError("not a regular file: " + fi.Mode().String()) + } + return f, fi.Size(), nil +} diff --git a/internal/security/scanner/candidate_open_test.go b/internal/security/scanner/candidate_open_test.go new file mode 100644 index 000000000..78ff979b7 --- /dev/null +++ b/internal/security/scanner/candidate_open_test.go @@ -0,0 +1,178 @@ +package scanner + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// requireCandidateErr asserts err is a *candidateReadError with the reason and +// returns it for further checks. +func requireCandidateErr(t *testing.T, err error, reason string) *candidateReadError { + t.Helper() + var ce *candidateReadError + require.Error(t, err) + require.True(t, errors.As(err, &ce), "want *candidateReadError, got %T: %v", err, err) + require.Equal(t, reason, ce.Reason, "detail: %s", ce.Detail) + return ce +} + +func writeCandidate(t *testing.T, name string, data []byte) string { + t.Helper() + p := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(p, data, 0o644)) + return p +} + +func TestReadCandidateFile_RegularFile(t *testing.T) { + p := writeCandidate(t, "bundle.json", []byte(`{"ok":true}`)) + got, err := readCandidateFile(context.Background(), p, 1024) + require.NoError(t, err) + require.Equal(t, []byte(`{"ok":true}`), got) +} + +func TestReadCandidateFile_EmptyFile(t *testing.T) { + p := writeCandidate(t, "empty", nil) + got, err := readCandidateFile(context.Background(), p, 16) + require.NoError(t, err) + require.Empty(t, got) +} + +func TestReadCandidateFile_CeilingBoundary(t *testing.T) { + const max = 64 + at := writeCandidate(t, "at", bytes.Repeat([]byte{'a'}, max)) + got, err := readCandidateFile(context.Background(), at, max) + require.NoError(t, err, "a file of exactly max bytes must load") + require.Len(t, got, max) + + over := writeCandidate(t, "over", bytes.Repeat([]byte{'a'}, max+1)) + _, err = readCandidateFile(context.Background(), over, max) + ce := requireCandidateErr(t, err, RejectCeilingExceeded) + require.False(t, ce.NotExist) + require.Contains(t, ce.Detail, "64") + require.Contains(t, ce.Detail, "65") +} + +func TestReadCandidateFile_GrowthPastCeilingDuringRead(t *testing.T) { + // The descriptor's size passes the fstat check, but the reader then + // delivers more than max bytes (the file grew). The read is bounded to + // max+1 and refused as ceiling_exceeded. + const max = 32 + p := writeCandidate(t, "small", []byte("tiny")) + candidateReaderWrap = func(r io.Reader) io.Reader { + return io.MultiReader(r, bytes.NewReader(bytes.Repeat([]byte{'z'}, 4*max))) + } + t.Cleanup(func() { candidateReaderWrap = nil }) + + _, err := readCandidateFile(context.Background(), p, max) + requireCandidateErr(t, err, RejectCeilingExceeded) +} + +func TestReadCandidateFile_MissingFile(t *testing.T) { + _, err := readCandidateFile(context.Background(), filepath.Join(t.TempDir(), "nope"), 16) + ce := requireCandidateErr(t, err, RejectReadError) + require.True(t, ce.NotExist, "ENOENT on open is the only NotExist case") +} + +func TestReadCandidateFile_DirectoryRefused(t *testing.T) { + _, err := readCandidateFile(context.Background(), t.TempDir(), 16) + ce := requireCandidateErr(t, err, RejectNotRegularFile) + require.False(t, ce.NotExist) +} + +func TestReadCandidateFile_NotExistOnlyForENOENT(t *testing.T) { + // A path whose parent is a regular file fails with ENOTDIR, which is a + // read failure, not "absent": the loader must not treat it as unsigned. + f := writeCandidate(t, "file", []byte("x")) + _, err := readCandidateFile(context.Background(), filepath.Join(f, "child"), 16) + ce := requireCandidateErr(t, err, RejectReadError) + require.False(t, ce.NotExist) +} + +func TestReadCandidateFile_CanceledContext(t *testing.T) { + p := writeCandidate(t, "b", []byte("x")) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := readCandidateFile(ctx, p, 16) + requireCandidateErr(t, err, RejectReadTimeout) +} + +// blockingReader blocks every Read until release is closed, standing in for a +// read parked on a stalled network filesystem. +type blockingReader struct { + entered chan struct{} + release chan struct{} + inner io.Reader +} + +func newBlockingReader(inner io.Reader) *blockingReader { + return &blockingReader{entered: make(chan struct{}, 1), release: make(chan struct{}), inner: inner} +} + +func (b *blockingReader) Read(p []byte) (int, error) { + select { + case b.entered <- struct{}{}: + default: + } + <-b.release + return b.inner.Read(p) +} + +func TestReadCandidateFile_ReadTimeoutAndParkedReaderCap(t *testing.T) { + p := writeCandidate(t, "slow", []byte("payload")) + + brCh := make(chan *blockingReader, 1) + candidateReaderWrap = func(r io.Reader) io.Reader { + br := newBlockingReader(r) + brCh <- br + return br + } + t.Cleanup(func() { candidateReaderWrap = nil }) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + start := time.Now() + _, err := readCandidateFile(ctx, p, 1024) + elapsed := time.Since(start) + requireCandidateErr(t, err, RejectReadTimeout) + require.Less(t, elapsed, 2*time.Second, "the budget, not the stalled read, bounds the call") + br := <-brCh + <-br.entered // the first reader really is parked inside Read + + // Second call on the same path: refused immediately, no new open. + var opens atomic.Int32 + candidateOpenHook = func(string) { opens.Add(1) } + t.Cleanup(func() { candidateOpenHook = nil }) + start = time.Now() + _, err = readCandidateFile(context.Background(), p, 1024) + ce := requireCandidateErr(t, err, RejectReadTimeout) + require.Less(t, time.Since(start), 500*time.Millisecond) + require.Contains(t, ce.Detail, "previous read still blocked") + require.Zero(t, opens.Load(), "a parked path must not be opened again") + + // A different path is unaffected by the parked one. + other := writeCandidate(t, "other", []byte("fine")) + candidateReaderWrap = nil + got, err := readCandidateFile(context.Background(), other, 1024) + require.NoError(t, err) + require.Equal(t, []byte("fine"), got) + + // Releasing the parked reader frees the path. + close(br.release) + require.Eventually(t, func() bool { + candidateParked.mu.Lock() + defer candidateParked.mu.Unlock() + return len(candidateParked.paths) == 0 + }, 2*time.Second, 5*time.Millisecond) + got, err = readCandidateFile(context.Background(), p, 1024) + require.NoError(t, err) + require.Equal(t, []byte("payload"), got) +} diff --git a/internal/security/scanner/candidate_open_unix.go b/internal/security/scanner/candidate_open_unix.go new file mode 100644 index 000000000..632efcd35 --- /dev/null +++ b/internal/security/scanner/candidate_open_unix.go @@ -0,0 +1,77 @@ +//go:build unix + +package scanner + +import ( + "errors" + "fmt" + "os" + "syscall" +) + +// openCandidateDescriptor opens path for reading without following a symlink +// at the final component and without ever blocking on the open, then decides +// everything about the file from the DESCRIPTOR (fstat), never the path. +// +// - O_NOFOLLOW makes the kernel refuse a symlink atomically (ELOOP; some BSDs +// return EMLINK), so there is no check-then-open window. +// - O_NONBLOCK keeps a FIFO's open from waiting for a writer; the fstat that +// follows then refuses it. The flag is a no-op for regular files. +// - O_CLOEXEC keeps the descriptor out of any child process. +// +// The raw syscall is used rather than os.OpenFile so the nonblocking flag can +// be cleared before the descriptor is wrapped: os.NewFile registers a +// nonblocking descriptor with the runtime poller, and a regular file does not +// belong there. +func openCandidateDescriptor(path string) (*os.File, int64, error) { + var fd int + for { + var err error + fd, err = syscall.Open(path, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK|syscall.O_CLOEXEC, 0) + if err == nil { + break + } + if errors.Is(err, syscall.EINTR) { + continue + } + if errors.Is(err, syscall.ELOOP) || errors.Is(err, syscall.EMLINK) { + return nil, 0, notRegularError("symbolic link") + } + return nil, 0, &os.PathError{Op: "open", Path: path, Err: err} + } + + var st syscall.Stat_t + if err := syscall.Fstat(fd, &st); err != nil { + _ = syscall.Close(fd) + return nil, 0, &os.PathError{Op: "fstat", Path: path, Err: err} + } + if st.Mode&syscall.S_IFMT != syscall.S_IFREG { + _ = syscall.Close(fd) + return nil, 0, notRegularError(fmt.Sprintf("file type %s", unixTypeName(uint32(st.Mode)&uint32(syscall.S_IFMT)))) + } + if err := syscall.SetNonblock(fd, false); err != nil { + _ = syscall.Close(fd) + return nil, 0, &os.PathError{Op: "fcntl", Path: path, Err: err} + } + return os.NewFile(uintptr(fd), path), int64(st.Size), nil +} + +// unixTypeName names an S_IFMT value for the rejection detail. +func unixTypeName(t uint32) string { + switch t { + case syscall.S_IFIFO: + return "fifo" + case syscall.S_IFDIR: + return "directory" + case syscall.S_IFCHR: + return "character device" + case syscall.S_IFBLK: + return "block device" + case syscall.S_IFSOCK: + return "socket" + case syscall.S_IFLNK: + return "symbolic link" + default: + return fmt.Sprintf("%#o", t) + } +} diff --git a/internal/security/scanner/candidate_open_unix_test.go b/internal/security/scanner/candidate_open_unix_test.go new file mode 100644 index 000000000..830a0abd0 --- /dev/null +++ b/internal/security/scanner/candidate_open_unix_test.go @@ -0,0 +1,98 @@ +//go:build unix + +package scanner + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// readWithin runs readCandidateFile and fails the test if it does not return +// within d: a FIFO open must never park the caller. +func readWithin(t *testing.T, d time.Duration, path string, max int64) ([]byte, error) { + t.Helper() + type res struct { + b []byte + err error + } + ch := make(chan res, 1) + go func() { + b, err := readCandidateFile(context.Background(), path, max) + ch <- res{b, err} + }() + select { + case r := <-ch: + return r.b, r.err + case <-time.After(d): + t.Fatalf("readCandidateFile(%s) blocked for more than %s", path, d) + return nil, nil + } +} + +func TestReadCandidateFile_FIFORefusedWithoutBlocking(t *testing.T) { + p := filepath.Join(t.TempDir(), "fifo") + makeTestFIFO(t, p) + _, err := readWithin(t, time.Second, p, 1024) + requireCandidateErr(t, err, RejectNotRegularFile) +} + +func TestReadCandidateFile_SymlinkToFIFORefused(t *testing.T) { + dir := t.TempDir() + fifo := filepath.Join(dir, "fifo") + makeTestFIFO(t, fifo) + link := filepath.Join(dir, "link") + require.NoError(t, os.Symlink(fifo, link)) + _, err := readWithin(t, time.Second, link, 1024) + requireCandidateErr(t, err, RejectNotRegularFile) +} + +func TestReadCandidateFile_SymlinkToRegularRefused(t *testing.T) { + target := writeCandidate(t, "real", []byte("secret")) + link := filepath.Join(t.TempDir(), "link") + require.NoError(t, os.Symlink(target, link)) + _, err := readCandidateFile(context.Background(), link, 1024) + ce := requireCandidateErr(t, err, RejectNotRegularFile) + require.False(t, ce.NotExist) +} + +func TestReadCandidateFile_DanglingSymlinkRefused(t *testing.T) { + link := filepath.Join(t.TempDir(), "dangling") + require.NoError(t, os.Symlink(filepath.Join(t.TempDir(), "gone"), link)) + _, err := readCandidateFile(context.Background(), link, 1024) + requireCandidateErr(t, err, RejectNotRegularFile) +} + +// SC-011a: the path is a regular file when resolved but a FIFO by the time it +// is opened. The descriptor-based check must refuse it, not block. +func TestReadCandidateFile_SwapToFIFOBetweenResolutionAndOpen(t *testing.T) { + p := writeCandidate(t, "swap", []byte("regular")) + candidateOpenHook = func(path string) { + require.NoError(t, os.Remove(path)) + makeTestFIFO(t, path) + } + t.Cleanup(func() { candidateOpenHook = nil }) + + _, err := readWithin(t, time.Second, p, 1024) + requireCandidateErr(t, err, RejectNotRegularFile) +} + +func TestReadCandidateFile_SwapToSymlinkBetweenResolutionAndOpen(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target") + require.NoError(t, os.WriteFile(target, []byte("other"), 0o644)) + p := filepath.Join(dir, "cand") + require.NoError(t, os.WriteFile(p, []byte("regular"), 0o644)) + candidateOpenHook = func(path string) { + require.NoError(t, os.Remove(path)) + require.NoError(t, os.Symlink(target, path)) + } + t.Cleanup(func() { candidateOpenHook = nil }) + + _, err := readCandidateFile(context.Background(), p, 1024) + requireCandidateErr(t, err, RejectNotRegularFile) +} diff --git a/internal/security/scanner/candidate_open_windows.go b/internal/security/scanner/candidate_open_windows.go new file mode 100644 index 000000000..169b3d1c2 --- /dev/null +++ b/internal/security/scanner/candidate_open_windows.go @@ -0,0 +1,59 @@ +//go:build windows + +package scanner + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +// openCandidateDescriptor is the Windows counterpart of the unix open, modelled +// on internal/codescripts/open_windows.go. FILE_FLAG_OPEN_REPARSE_POINT makes +// CreateFile open a symlink or junction ITSELF instead of resolving it (the +// O_NOFOLLOW equivalent), and GetFileInformationByHandle on that handle then +// refuses a reparse point or a directory, so the type decision is made on the +// handle and there is no check-then-open window. Windows has no filesystem +// FIFOs. The share mode includes FILE_SHARE_DELETE so this read cannot block an +// atomic replace of the bundle file by a publisher. +func openCandidateDescriptor(path string) (*os.File, int64, error) { + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, 0, err + } + h, err := windows.CreateFile(p, + windows.GENERIC_READ, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0) + if err != nil { + // Without FILE_FLAG_BACKUP_SEMANTICS (deliberately not requested: it + // would let a holder of SeBackupPrivilege read past ACLs) CreateFile + // refuses a directory with ERROR_ACCESS_DENIED before any attribute is + // visible; classify that one case from the attributes. + if errors.Is(err, windows.ERROR_ACCESS_DENIED) { + if attrs, aerr := windows.GetFileAttributes(p); aerr == nil && attrs&windows.FILE_ATTRIBUTE_DIRECTORY != 0 { + return nil, 0, notRegularError("directory") + } + } + return nil, 0, &os.PathError{Op: "open", Path: path, Err: err} + } + var fi windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(h, &fi); err != nil { + _ = windows.CloseHandle(h) + return nil, 0, &os.PathError{Op: "GetFileInformationByHandle", Path: path, Err: err} + } + switch { + case fi.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0: + _ = windows.CloseHandle(h) + return nil, 0, notRegularError("reparse point") + case fi.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0: + _ = windows.CloseHandle(h) + return nil, 0, notRegularError("directory") + } + size := int64(fi.FileSizeHigh)<<32 | int64(fi.FileSizeLow) + return os.NewFile(uintptr(h), path), size, nil +} diff --git a/internal/security/scanner/candidate_open_windows_test.go b/internal/security/scanner/candidate_open_windows_test.go new file mode 100644 index 000000000..bb5958a0f --- /dev/null +++ b/internal/security/scanner/candidate_open_windows_test.go @@ -0,0 +1,22 @@ +//go:build windows + +package scanner + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +// Windows has no filesystem FIFOs, and creating a symlink needs a privilege +// CI runners do not always hold, so the FIFO and symlink behaviours are +// unix-only tests. The shared tests already cover directory, missing-file and +// ceiling behaviour on Windows; this pins that the CreateFile-based open path +// reads a regular file. +func TestReadCandidateFile_WindowsRegularFile(t *testing.T) { + p := writeCandidate(t, "bundle.json", []byte("win")) + got, err := readCandidateFile(context.Background(), p, 16) + require.NoError(t, err) + require.Equal(t, []byte("win"), got) +} From 18e47aff769be59f999d6077d3749ff14bba7b33 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 13:06:15 +0300 Subject: [PATCH 07/35] feat(security): Spec 101 US1 signed bundle sidecar package and tpa-db CLI Add internal/security/bundlesig (strict sidecar grammar rows 1-10, trust set, Ed25519 verify, key-id agreement, canonical SignSidecar) and cmd/tpa-db (keygen, sign, verify). Byte-level fixtures are generated from a fixed test seed and checked in. Related #938 --- cmd/tpa-db/main.go | 294 ++++++++++++++++ cmd/tpa-db/main_test.go | 227 +++++++++++++ internal/security/bundlesig/fixtures_test.go | 152 +++++++++ internal/security/bundlesig/sidecar.go | 245 ++++++++++++++ internal/security/bundlesig/sidecar_test.go | 227 +++++++++++++ .../bundlesig/testdata/accept-4096-bytes.sig | 1 + .../testdata/accept-all-zero-signature.sig | 1 + .../testdata/accept-trailing-newline.sig | 1 + .../reject-key_id_mismatch.bundle.json | 1 + .../testdata/reject-key_id_mismatch.sig | 1 + .../testdata/reject-key_not_trusted.sig | 1 + .../reject-sidecar_algorithm_unsupported.sig | 1 + .../reject-sidecar_duplicate_field.sig | 1 + .../reject-sidecar_key_id_malformed.sig | 1 + .../testdata/reject-sidecar_malformed.sig | 1 + .../testdata/reject-sidecar_missing_field.sig | 1 + .../reject-sidecar_signature_malformed.sig | 1 + .../testdata/reject-sidecar_too_large.sig | 1 + .../testdata/reject-sidecar_type.sig | 1 + .../testdata/reject-sidecar_unknown_field.sig | 1 + .../reject-sidecar_version_unsupported.sig | 1 + .../testdata/reject-signature_invalid.sig | 1 + .../security/bundlesig/testdata/valid.json | 1 + .../bundlesig/testdata/valid.json.sig | 1 + internal/security/bundlesig/verify.go | 119 +++++++ internal/security/bundlesig/verify_test.go | 317 ++++++++++++++++++ 26 files changed, 1600 insertions(+) create mode 100644 cmd/tpa-db/main.go create mode 100644 cmd/tpa-db/main_test.go create mode 100644 internal/security/bundlesig/fixtures_test.go create mode 100644 internal/security/bundlesig/sidecar.go create mode 100644 internal/security/bundlesig/sidecar_test.go create mode 100644 internal/security/bundlesig/testdata/accept-4096-bytes.sig create mode 100644 internal/security/bundlesig/testdata/accept-all-zero-signature.sig create mode 100644 internal/security/bundlesig/testdata/accept-trailing-newline.sig create mode 100644 internal/security/bundlesig/testdata/reject-key_id_mismatch.bundle.json create mode 100644 internal/security/bundlesig/testdata/reject-key_id_mismatch.sig create mode 100644 internal/security/bundlesig/testdata/reject-key_not_trusted.sig create mode 100644 internal/security/bundlesig/testdata/reject-sidecar_algorithm_unsupported.sig create mode 100644 internal/security/bundlesig/testdata/reject-sidecar_duplicate_field.sig create mode 100644 internal/security/bundlesig/testdata/reject-sidecar_key_id_malformed.sig create mode 100644 internal/security/bundlesig/testdata/reject-sidecar_malformed.sig create mode 100644 internal/security/bundlesig/testdata/reject-sidecar_missing_field.sig create mode 100644 internal/security/bundlesig/testdata/reject-sidecar_signature_malformed.sig create mode 100644 internal/security/bundlesig/testdata/reject-sidecar_too_large.sig create mode 100644 internal/security/bundlesig/testdata/reject-sidecar_type.sig create mode 100644 internal/security/bundlesig/testdata/reject-sidecar_unknown_field.sig create mode 100644 internal/security/bundlesig/testdata/reject-sidecar_version_unsupported.sig create mode 100644 internal/security/bundlesig/testdata/reject-signature_invalid.sig create mode 100644 internal/security/bundlesig/testdata/valid.json create mode 100644 internal/security/bundlesig/testdata/valid.json.sig create mode 100644 internal/security/bundlesig/verify.go create mode 100644 internal/security/bundlesig/verify_test.go diff --git a/cmd/tpa-db/main.go b/cmd/tpa-db/main.go new file mode 100644 index 000000000..d3cfdbc3f --- /dev/null +++ b/cmd/tpa-db/main.go @@ -0,0 +1,294 @@ +// Command tpa-db is the operator tool for Spec 101 signed TPA bundles. +// +// Subcommands: +// +// keygen --out DIR +// write DIR/operator.key (0600, base64 of the 64-byte Ed25519 private +// key) and DIR/operator.pub (base64 of the 32-byte public key); print the +// public key and its key id. Never overwrites existing files. +// sign --bundle PATH --key KEYFILE [--out PATH] +// write the detached sidecar .sig for the bundle. Bundle bytes are +// never edited: the operator authors sequence, key_id, trust_epoch and +// generated_at in the bundle itself. Refuses a bundle whose manifest +// key_id differs from the signing key's id. +// verify --bundle PATH --signer BASE64PUB +// verify .sig against one trusted signer and the manifest key_id; +// print OK, or the contract rejection code with exit status 1. +// +// Exit status: 0 success, 1 failure, 2 usage error. +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/security/bundlesig" +) + +const ( + privKeyFile = "operator.key" + pubKeyFile = "operator.pub" + // authority labels the single operator root inside the one-key trust set + // that verify builds; this tool has no notion of a second root. + authority = "operator" +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func usage(w io.Writer) { + fmt.Fprint(w, `usage: tpa-db [flags] + +commands: + keygen --out DIR + sign --bundle PATH --key KEYFILE [--out PATH] + verify --bundle PATH --signer BASE64PUB +`) +} + +// run is main without the process exit, so tests can drive it. +func run(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + usage(stderr) + return 2 + } + switch args[0] { + case "keygen": + return cmdKeygen(args[1:], stdout, stderr) + case "sign": + return cmdSign(args[1:], stdout, stderr) + case "verify": + return cmdVerify(args[1:], stdout, stderr) + case "-h", "--help", "help": + usage(stdout) + return 0 + default: + fmt.Fprintf(stderr, "tpa-db: unknown command %q\n", args[0]) + usage(stderr) + return 2 + } +} + +func newFlagSet(name string, stderr io.Writer) *flag.FlagSet { + fs := flag.NewFlagSet("tpa-db "+name, flag.ContinueOnError) + fs.SetOutput(stderr) + return fs +} + +func base64Std(b []byte) string { return base64.StdEncoding.EncodeToString(b) } + +func cmdKeygen(args []string, stdout, stderr io.Writer) int { + fs := newFlagSet("keygen", stderr) + out := fs.String("out", "", "directory to write operator.key and operator.pub into") + if err := fs.Parse(args); err != nil { + return 2 + } + if *out == "" || fs.NArg() != 0 { + fmt.Fprintln(stderr, "keygen: --out DIR is required") + return 2 + } + + privPath, pubPath := filepath.Join(*out, privKeyFile), filepath.Join(*out, pubKeyFile) + for _, p := range []string{privPath, pubPath} { + if _, err := os.Lstat(p); err == nil { + fmt.Fprintf(stderr, "keygen: %s already exists; refusing to overwrite\n", p) + return 1 + } + } + if err := os.MkdirAll(*out, 0o700); err != nil { + fmt.Fprintf(stderr, "keygen: %v\n", err) + return 1 + } + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + fmt.Fprintf(stderr, "keygen: %v\n", err) + return 1 + } + if err := createExclusive(privPath, []byte(base64Std(priv)+"\n"), 0o600); err != nil { + fmt.Fprintf(stderr, "keygen: %v\n", err) + return 1 + } + if err := createExclusive(pubPath, []byte(base64Std(pub)+"\n"), 0o644); err != nil { + _ = os.Remove(privPath) // do not leave half a key pair behind + fmt.Fprintf(stderr, "keygen: %v\n", err) + return 1 + } + fmt.Fprintf(stdout, "public_key: %s\nkey_id: %s\n", base64Std(pub), bundlesig.KeyID(pub)) + return 0 +} + +// createExclusive writes a new file and fails if it already exists, closing +// the race between the existence check and the write. +func createExclusive(path string, data []byte, perm os.FileMode) error { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm) + if err != nil { + if errors.Is(err, os.ErrExist) { + return fmt.Errorf("%s already exists; refusing to overwrite", path) + } + return err + } + if _, err := f.Write(data); err != nil { + _ = f.Close() + _ = os.Remove(path) + return err + } + if err := f.Close(); err != nil { + _ = os.Remove(path) + return err + } + return nil +} + +// loadPrivateKey reads a keygen-format key file and checks that its embedded +// public half really is the one derived from its seed. +func loadPrivateKey(path string) (ed25519.PrivateKey, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + s := strings.TrimSpace(string(raw)) + b, err := base64.StdEncoding.Strict().DecodeString(s) + if err != nil || len(b) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("%s is not a base64-encoded %d-byte Ed25519 private key", path, ed25519.PrivateKeySize) + } + priv := ed25519.PrivateKey(b) + if !priv.Equal(ed25519.NewKeyFromSeed(priv.Seed())) { + return nil, fmt.Errorf("%s: public half does not match the private seed", path) + } + return priv, nil +} + +// manifestKeyID extracts only the top-level "key_id" string of a bundle. It +// returns ok=false for a bundle that is not a JSON object or has no string +// key_id. +func manifestKeyID(bundle []byte) (string, bool) { + var m struct { + KeyID *string `json:"key_id"` + } + if err := json.Unmarshal(bundle, &m); err != nil || m.KeyID == nil { + return "", false + } + return *m.KeyID, true +} + +func cmdSign(args []string, stdout, stderr io.Writer) int { + fs := newFlagSet("sign", stderr) + bundlePath := fs.String("bundle", "", "bundle file to sign") + keyPath := fs.String("key", "", "operator private key file (from keygen)") + outPath := fs.String("out", "", "write the bundle copy here and the sidecar to .sig (default: in place)") + if err := fs.Parse(args); err != nil { + return 2 + } + if *bundlePath == "" || *keyPath == "" || fs.NArg() != 0 { + fmt.Fprintln(stderr, "sign: --bundle and --key are required") + return 2 + } + + bundle, err := os.ReadFile(*bundlePath) + if err != nil { + fmt.Fprintf(stderr, "sign: %v\n", err) + return 1 + } + priv, err := loadPrivateKey(*keyPath) + if err != nil { + fmt.Fprintf(stderr, "sign: %v\n", err) + return 1 + } + + // An artifact whose manifest names another key would be rejected by every + // loader at contract row 13, so refuse to produce it. + signerID := bundlesig.KeyID(priv.Public().(ed25519.PublicKey)) + got, ok := manifestKeyID(bundle) + if !ok { + fmt.Fprintf(stderr, "sign: %s: bundle is not a JSON object with a string \"key_id\"\n", *bundlePath) + return 1 + } + if got != signerID { + fmt.Fprintf(stderr, "sign: %s: manifest key_id %q != signing key id %q\n", bundlesig.CodeKeyIDMismatch, got, signerID) + return 1 + } + + dest := *bundlePath + if *outPath != "" && *outPath != *bundlePath { + dest = *outPath + if err := os.WriteFile(dest, bundle, 0o644); err != nil { + fmt.Fprintf(stderr, "sign: %v\n", err) + return 1 + } + } + if err := os.WriteFile(dest+".sig", bundlesig.SignSidecar(priv, bundle), 0o644); err != nil { + fmt.Fprintf(stderr, "sign: %v\n", err) + return 1 + } + fmt.Fprintf(stdout, "signed %s with key %s\nsidecar: %s.sig\n", dest, signerID, dest) + return 0 +} + +func cmdVerify(args []string, stdout, stderr io.Writer) int { + fs := newFlagSet("verify", stderr) + bundlePath := fs.String("bundle", "", "bundle file; its sidecar is .sig") + signer := fs.String("signer", "", "trusted signer: base64 of the 32-byte Ed25519 public key") + if err := fs.Parse(args); err != nil { + return 2 + } + if *bundlePath == "" || *signer == "" || fs.NArg() != 0 { + fmt.Fprintln(stderr, "verify: --bundle and --signer are required") + return 2 + } + + fail := func(err error) int { + if code := bundlesig.CodeOf(err); code != "" { + fmt.Fprintln(stdout, code) + } + fmt.Fprintf(stderr, "verify: %v\n", err) + return 1 + } + + pub, err := bundlesig.ParsePublicKey(*signer) + if err != nil { + fmt.Fprintf(stderr, "verify: --signer: %v\n", err) + return 2 + } + bundle, err := os.ReadFile(*bundlePath) + if err != nil { + fmt.Fprintf(stderr, "verify: %v\n", err) + return 1 + } + raw, err := os.ReadFile(*bundlePath + ".sig") + if err != nil { + fmt.Fprintf(stderr, "verify: %v\n", err) + return 1 + } + sc, err := bundlesig.ParseSidecar(raw) + if err != nil { + return fail(err) + } + ts, err := bundlesig.NewTrustSet(bundlesig.Key{ID: bundlesig.KeyID(pub), Public: pub, Authority: authority}) + if err != nil { + fmt.Fprintf(stderr, "verify: %v\n", err) + return 1 + } + verified, err := ts.Verify(bundle, sc) + if err != nil { + return fail(err) + } + // An unparseable manifest is not a contract code; treat it as an empty + // key id, which CheckKeyIDAgreement rejects as a mismatch. + manifestID, _ := manifestKeyID(bundle) + if err := bundlesig.CheckKeyIDAgreement(manifestID, sc, verified); err != nil { + return fail(err) + } + fmt.Fprintln(stdout, "OK") + return 0 +} diff --git a/cmd/tpa-db/main_test.go b/cmd/tpa-db/main_test.go new file mode 100644 index 000000000..452892737 --- /dev/null +++ b/cmd/tpa-db/main_test.go @@ -0,0 +1,227 @@ +package main + +import ( + "bytes" + "crypto/ed25519" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/security/bundlesig" +) + +func runCLI(t *testing.T, args ...string) (code int, stdout, stderr string) { + t.Helper() + var out, errb bytes.Buffer + code = run(args, &out, &errb) + return code, out.String(), errb.String() +} + +// keygen creates a key pair in a fresh dir and returns the dir, the base64 +// public key and its key id as printed. +func keygen(t *testing.T) (dir, pub, keyID string) { + t.Helper() + dir = filepath.Join(t.TempDir(), "keys") + code, out, errs := runCLI(t, "keygen", "--out", dir) + if code != 0 { + t.Fatalf("keygen exit %d: %s", code, errs) + } + for _, line := range strings.Split(out, "\n") { + switch { + case strings.HasPrefix(line, "public_key: "): + pub = strings.TrimPrefix(line, "public_key: ") + case strings.HasPrefix(line, "key_id: "): + keyID = strings.TrimPrefix(line, "key_id: ") + } + } + if pub == "" || keyID == "" { + t.Fatalf("keygen output lacks public_key/key_id:\n%s", out) + } + return dir, pub, keyID +} + +func writeBundle(t *testing.T, keyID string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "scanner-bundle.json") + body := fmt.Sprintf(`{"sequence":3,"key_id":%q,"trust_epoch":1,"generated_at":"2026-10-20T00:00:00Z","rules":[]}`+"\n", keyID) + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +func TestKeygenFilesAndRefusesOverwrite(t *testing.T) { + dir, pubB64, keyID := keygen(t) + + st, err := os.Stat(filepath.Join(dir, "operator.key")) + if err != nil { + t.Fatal(err) + } + if st.Mode().Perm() != 0o600 { + t.Fatalf("operator.key mode = %v, want 0600", st.Mode().Perm()) + } + pubFile, err := os.ReadFile(filepath.Join(dir, "operator.pub")) + if err != nil || strings.TrimSpace(string(pubFile)) != pubB64 { + t.Fatalf("operator.pub = %q, %v", pubFile, err) + } + pub, err := bundlesig.ParsePublicKey(pubB64) + if err != nil || bundlesig.KeyID(pub) != keyID { + t.Fatalf("printed key id does not match printed public key: %v", err) + } + + before, _ := os.ReadFile(filepath.Join(dir, "operator.key")) + code, _, errs := runCLI(t, "keygen", "--out", dir) + if code == 0 || !strings.Contains(errs, "exists") { + t.Fatalf("second keygen: exit %d, stderr %q; want refusal", code, errs) + } + after, _ := os.ReadFile(filepath.Join(dir, "operator.key")) + if !bytes.Equal(before, after) { + t.Fatal("keygen overwrote an existing key") + } +} + +func TestSignVerifyRoundTrip(t *testing.T) { + dir, pub, keyID := keygen(t) + bundle := writeBundle(t, keyID) + orig, _ := os.ReadFile(bundle) + + code, _, errs := runCLI(t, "sign", "--bundle", bundle, "--key", filepath.Join(dir, "operator.key")) + if code != 0 { + t.Fatalf("sign exit %d: %s", code, errs) + } + // In place and byte-preserving: sign never edits bundle bytes. + if now, _ := os.ReadFile(bundle); !bytes.Equal(now, orig) { + t.Fatal("sign modified the bundle bytes") + } + sidecar, err := os.ReadFile(bundle + ".sig") + if err != nil { + t.Fatalf("no sidecar: %v", err) + } + if _, err := bundlesig.ParseSidecar(sidecar); err != nil { + t.Fatalf("sidecar does not parse: %v", err) + } + + code, out, errs := runCLI(t, "verify", "--bundle", bundle, "--signer", pub) + if code != 0 || strings.TrimSpace(out) != "OK" { + t.Fatalf("verify exit %d out %q err %q", code, out, errs) + } + + // A tampered bundle is rejected with the contract code and exit 1. + if err := os.WriteFile(bundle, append(orig, ' '), 0o644); err != nil { + t.Fatal(err) + } + code, out, errs = runCLI(t, "verify", "--bundle", bundle, "--signer", pub) + if code != 1 || !strings.Contains(out+errs, bundlesig.CodeSignatureInvalid) { + t.Fatalf("tampered verify: exit %d out %q err %q", code, out, errs) + } +} + +func TestSignOutCopy(t *testing.T) { + dir, pub, keyID := keygen(t) + bundle := writeBundle(t, keyID) + out := filepath.Join(t.TempDir(), "published", "bundle.json") + if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil { + t.Fatal(err) + } + if code, _, errs := runCLI(t, "sign", "--bundle", bundle, "--key", filepath.Join(dir, "operator.key"), "--out", out); code != 0 { + t.Fatalf("sign exit %d: %s", code, errs) + } + a, _ := os.ReadFile(bundle) + b, _ := os.ReadFile(out) + if !bytes.Equal(a, b) { + t.Fatal("--out copy differs from the source bundle") + } + if _, err := os.Stat(bundle + ".sig"); err == nil { + t.Fatal("sidecar written next to the source instead of --out") + } + if code, o, e := runCLI(t, "verify", "--bundle", out, "--signer", pub); code != 0 { + t.Fatalf("verify out copy: exit %d %q %q", code, o, e) + } +} + +// A bundle whose manifest key_id is not the signing key's id would produce an +// artifact every loader rejects at row 13, so sign refuses to produce it. +func TestSignRefusesKeyIDMismatch(t *testing.T) { + dir, _, _ := keygen(t) + _, _, otherID := keygen(t) + bundle := writeBundle(t, otherID) + + code, _, errs := runCLI(t, "sign", "--bundle", bundle, "--key", filepath.Join(dir, "operator.key")) + if code == 0 || !strings.Contains(errs, bundlesig.CodeKeyIDMismatch) { + t.Fatalf("sign: exit %d stderr %q; want key_id_mismatch refusal", code, errs) + } + if _, err := os.Stat(bundle + ".sig"); err == nil { + t.Fatal("a sidecar was written despite the refusal") + } +} + +func TestSignRefusesBundleWithoutKeyID(t *testing.T) { + dir, _, _ := keygen(t) + p := filepath.Join(t.TempDir(), "b.json") + if err := os.WriteFile(p, []byte(`{"sequence":1}`), 0o644); err != nil { + t.Fatal(err) + } + if code, _, _ := runCLI(t, "sign", "--bundle", p, "--key", filepath.Join(dir, "operator.key")); code == 0 { + t.Fatal("signed a bundle with no manifest key_id") + } + if err := os.WriteFile(p, []byte(`not json`), 0o644); err != nil { + t.Fatal(err) + } + if code, _, _ := runCLI(t, "sign", "--bundle", p, "--key", filepath.Join(dir, "operator.key")); code == 0 { + t.Fatal("signed a non-JSON bundle") + } +} + +func TestVerifyWrongSignerAndMissingSidecar(t *testing.T) { + dir, _, keyID := keygen(t) + _, otherPub, _ := keygen(t) + bundle := writeBundle(t, keyID) + + if code, _, _ := runCLI(t, "verify", "--bundle", bundle, "--signer", otherPub); code != 1 { + t.Fatalf("verify with no sidecar: exit %d, want 1", code) + } + if code, _, errs := runCLI(t, "sign", "--bundle", bundle, "--key", filepath.Join(dir, "operator.key")); code != 0 { + t.Fatal(errs) + } + code, out, errs := runCLI(t, "verify", "--bundle", bundle, "--signer", otherPub) + if code != 1 || !strings.Contains(out+errs, bundlesig.CodeKeyNotTrusted) { + t.Fatalf("wrong signer: exit %d out %q err %q", code, out, errs) + } +} + +// verify enforces row 13 against the manifest: a genuine signature by the +// trusted key over a bundle whose manifest names someone else. +func TestVerifyManifestKeyIDMismatch(t *testing.T) { + _, _, otherID := keygen(t) + // Sign with a throwaway key by hand so the CLI's own refusal is bypassed. + dir2 := t.TempDir() + seed := bytes.Repeat([]byte{9}, ed25519.SeedSize) + priv := ed25519.NewKeyFromSeed(seed) + bundle := filepath.Join(dir2, "b.json") + body := []byte(fmt.Sprintf(`{"sequence":1,"key_id":%q}`, otherID)) + if err := os.WriteFile(bundle, body, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(bundle+".sig", bundlesig.SignSidecar(priv, body), 0o644); err != nil { + t.Fatal(err) + } + signer := base64Std(priv.Public().(ed25519.PublicKey)) + code, out, errs := runCLI(t, "verify", "--bundle", bundle, "--signer", signer) + if code != 1 || !strings.Contains(out+errs, bundlesig.CodeKeyIDMismatch) { + t.Fatalf("exit %d out %q err %q; want key_id_mismatch", code, out, errs) + } +} + +func TestUsage(t *testing.T) { + if code, _, _ := runCLI(t); code != 2 { + t.Fatalf("no args: exit %d, want 2", code) + } + if code, _, _ := runCLI(t, "frobnicate"); code != 2 { + t.Fatalf("unknown subcommand: exit %d, want 2", code) + } + if code, _, _ := runCLI(t, "sign"); code != 2 { + t.Fatalf("sign without flags: exit %d, want 2", code) + } +} diff --git a/internal/security/bundlesig/fixtures_test.go b/internal/security/bundlesig/fixtures_test.go new file mode 100644 index 000000000..26cb466dd --- /dev/null +++ b/internal/security/bundlesig/fixtures_test.go @@ -0,0 +1,152 @@ +package bundlesig + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "flag" + "fmt" + "os" + "path/filepath" + "testing" +) + +// update regenerates testdata/ from the deterministic helper below. Fixtures +// are checked in so a reviewer reads the exact bytes a conforming loader must +// accept or reject; TestFixturesAreStable fails if they drift from the helper. +var update = flag.Bool("update", false, "regenerate testdata fixtures") + +// Constant seeds make the fixture keys reproducible. They are TEST keys: never +// configure either as a real trust anchor. +var ( + seedA = bytes.Repeat([]byte{0x01}, ed25519.SeedSize) + seedB = bytes.Repeat([]byte{0x02}, ed25519.SeedSize) +) + +func testKeyA() ed25519.PrivateKey { return ed25519.NewKeyFromSeed(seedA) } +func testKeyB() ed25519.PrivateKey { return ed25519.NewKeyFromSeed(seedB) } + +func pubOf(priv ed25519.PrivateKey) ed25519.PublicKey { + return priv.Public().(ed25519.PublicKey) +} + +// zeroSig is the canonical 88-char encoding of 64 zero bytes. +var zeroSig = base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize)) + +// rawSidecar builds sidecar bytes from already-encoded JSON values, in the +// canonical key order, with no escaping, so tests can inject malformed values. +func rawSidecar(version, algorithm, keyID, signature string) []byte { + return []byte(fmt.Sprintf(`{"sidecar_version":%s,"algorithm":%s,"key_id":%s,"signature":%s}`, + version, algorithm, keyID, signature)) +} + +func quote(s string) string { return `"` + s + `"` } + +// strSidecar quotes each field value (no escaping). +func strSidecar(version, algorithm, keyID, signature string) []byte { + return rawSidecar(quote(version), quote(algorithm), quote(keyID), quote(signature)) +} + +// bundleFor returns a small manifest-shaped bundle claiming the given key id. +func bundleFor(keyID string) []byte { + return []byte(fmt.Sprintf(`{"sequence":7,"key_id":%q,"trust_epoch":1,"generated_at":"2026-10-20T00:00:00Z","rules":[]}`+"\n", keyID)) +} + +// fixtureFiles is the full, deterministic content of testdata/. Sidecar +// fixtures are named .sig; rows 1-10 are pure parse rejections, rows +// 11-13 are rejected by verification against valid.json (13: against its own +// bundle). +func fixtureFiles() map[string][]byte { + a, b := testKeyA(), testKeyB() + idA, idB := KeyID(pubOf(a)), KeyID(pubOf(b)) + bundle := bundleFor(idA) + good := SignSidecar(a, bundle) + // Every fixture ends in a newline: the repo's end-of-file pre-commit hook + // would append one anyway, and fixtures must be byte-stable under it. The + // no-newline form is exercised directly on SignSidecar output. + nl := func(b []byte) []byte { return append(append([]byte{}, b...), '\n') } + // pad returns spaces, then good, then a final newline, n bytes in total. + // The padding leads rather than trails: the end-of-file hook rewrites a + // file that ends in whitespace before its newline. + pad := func(n int) []byte { + return nl(append(bytes.Repeat([]byte(" "), n-len(good)-1), good...)) + } + + files := map[string][]byte{ + "valid.json": bundle, + "valid.json.sig": nl(good), + "accept-trailing-newline.sig": append(append([]byte{}, good...), '\r', '\n'), + "accept-all-zero-signature.sig": strSidecar("1", "ed25519", idA, zeroSig), + // Exactly MaxSidecarBytes (whitespace padded) is accepted, one more is not. + "accept-4096-bytes.sig": pad(MaxSidecarBytes), + "reject-sidecar_too_large.sig": pad(MaxSidecarBytes + 1), + // Row 2: trailing non-whitespace bytes after a valid object. + "reject-sidecar_malformed.sig": append(append([]byte{}, good...), 'x'), + // Row 3: signature absent. + "reject-sidecar_missing_field.sig": []byte( + fmt.Sprintf(`{"sidecar_version":"1","algorithm":"ed25519","key_id":%q}`, idA)), + // Row 4: all four present plus a fifth (the dropped `sequence` duplicate). + "reject-sidecar_unknown_field.sig": []byte( + fmt.Sprintf(`{"sidecar_version":"1","algorithm":"ed25519","key_id":%q,"signature":%q,"sequence":"7"}`, idA, zeroSig)), + // Row 5: encoding/json would silently take the last "algorithm". + "reject-sidecar_duplicate_field.sig": []byte( + fmt.Sprintf(`{"sidecar_version":"1","algorithm":"ed25519","algorithm":"rsa","key_id":%q,"signature":%q}`, idA, zeroSig)), + // Row 6: sidecar_version is the number 1, not the string "1". + "reject-sidecar_type.sig": rawSidecar("1", quote("ed25519"), quote(idA), quote(zeroSig)), + "reject-sidecar_version_unsupported.sig": strSidecar("2", "ed25519", idA, zeroSig), + "reject-sidecar_algorithm_unsupported.sig": strSidecar("1", "rsa", idA, zeroSig), + "reject-sidecar_key_id_malformed.sig": strSidecar("1", "ed25519", "ABCDEF", zeroSig), + "reject-sidecar_signature_malformed.sig": strSidecar("1", "ed25519", idA, zeroSig[:87]), + // Row 11: well-formed, signed by a key outside the trust set {A}. + "reject-key_not_trusted.sig": SignSidecar(b, bundle), + // Row 12: trusted key id, signature of 64 zero bytes. + "reject-signature_invalid.sig": strSidecar("1", "ed25519", idA, zeroSig), + // Row 13: genuine signature by A over a bundle whose manifest names B. + "reject-key_id_mismatch.bundle.json": bundleFor(idB), + "reject-key_id_mismatch.sig": SignSidecar(a, bundleFor(idB)), + } + for name, data := range files { + if !bytes.HasSuffix(data, []byte("\n")) { + files[name] = nl(data) + } + } + return files +} + +func TestFixturesAreStable(t *testing.T) { + want := fixtureFiles() + if *update { + for name, data := range want { + if err := os.WriteFile(filepath.Join("testdata", name), data, 0o644); err != nil { + t.Fatal(err) + } + } + } + for name, data := range want { + got, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("fixture %s: %v (run go test -update)", name, err) + } + if !bytes.Equal(got, data) { + t.Errorf("fixture %s drifted from the deterministic helper (run go test -update and review the diff)", name) + } + } + entries, err := os.ReadDir("testdata") + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if _, ok := want[e.Name()]; !ok { + t.Errorf("testdata/%s is not produced by the helper", e.Name()) + } + } +} + +func readFixture(t *testing.T, name string) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatal(err) + } + return b +} diff --git a/internal/security/bundlesig/sidecar.go b/internal/security/bundlesig/sidecar.go new file mode 100644 index 000000000..b2d0c3732 --- /dev/null +++ b/internal/security/bundlesig/sidecar.go @@ -0,0 +1,245 @@ +// Package bundlesig implements the detached-signature sidecar format and the +// Ed25519 verification rules of Spec 101 (contracts/sidecar-format.md). +// +// It is a pure leaf package: no file I/O, no globals, stdlib only, and it must +// not import internal/security/scanner. The scanner decides what to do with a +// verification outcome (which authority is active, rollback, activation); this +// package only answers "is this sidecar well-formed" and "did this key sign +// exactly these bytes", and does so with a stable machine-readable code for +// every refusal so two implementations can be shown to reject identically. +package bundlesig + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "unicode/utf8" +) + +// Rejection codes, one per contract row 1..13. They are part of the wire +// contract (surfaced as LastRejection reasons) and must not be renamed. +const ( + CodeSidecarTooLarge = "sidecar_too_large" // row 1 + CodeSidecarMalformed = "sidecar_malformed" // row 2 + CodeSidecarMissingField = "sidecar_missing_field" // row 3 + CodeSidecarUnknownField = "sidecar_unknown_field" // row 4 + CodeSidecarDuplicateField = "sidecar_duplicate_field" // row 5 + CodeSidecarType = "sidecar_type" // row 6 + CodeSidecarVersionUnsupported = "sidecar_version_unsupported" // row 7 + CodeSidecarAlgorithmUnsupported = "sidecar_algorithm_unsupported" // row 8 + CodeSidecarKeyIDMalformed = "sidecar_key_id_malformed" // row 9 + CodeSidecarSignatureMalformed = "sidecar_signature_malformed" // row 10 + CodeKeyNotTrusted = "key_not_trusted" // row 11 + CodeSignatureInvalid = "signature_invalid" // row 12 + CodeKeyIDMismatch = "key_id_mismatch" // row 13 +) + +// MaxSidecarBytes is the FR-011 cap on a sidecar. It is checked before any +// parsing because the sidecar is read before anything is authenticated. +const MaxSidecarBytes = 4096 + +const ( + sidecarVersion = "1" + sidecarAlgorithm = "ed25519" + // signatureB64Len is the canonical padded base64 length of a 64-byte + // Ed25519 signature. + signatureB64Len = 88 +) + +// Error is a contract rejection carrying its stable reason code. Detail is +// human-oriented context and never part of the contract. +type Error struct { + Code string + Detail string +} + +func (e *Error) Error() string { + if e.Detail == "" { + return e.Code + } + return e.Code + ": " + e.Detail +} + +// CodeOf returns the contract reason code carried by err (looking through +// wrapping), or "" when err is nil or not a bundlesig rejection. +func CodeOf(err error) string { + var be *Error + if errors.As(err, &be) { + return be.Code + } + return "" +} + +func reject(code, format string, args ...any) error { + return &Error{Code: code, Detail: fmt.Sprintf(format, args...)} +} + +// Sidecar is a parsed, grammar-valid sidecar. Parsing proves only that the +// sidecar is well-formed; nothing here is authenticated until +// TrustSet.Verify succeeds. +type Sidecar struct { + Version string + Algorithm string + KeyID string + Signature []byte +} + +// The four required keys, in canonical order. +var sidecarKeys = [...]string{"sidecar_version", "algorithm", "key_id", "signature"} + +// ParseSidecar parses sidecar bytes under the contract's total grammar, +// evaluating rows 1..10 in numerical order; the first matching row wins so +// overlapping conditions (e.g. {"foo":1} is both missing-a-field and +// has-an-unknown-key) always report the same reason. +// +// JSON whitespace around the object is tolerated (a trailing newline is +// normal); any other trailing byte is sidecar_malformed. +func ParseSidecar(b []byte) (*Sidecar, error) { + // Row 1, before parsing anything. + if len(b) > MaxSidecarBytes { + return nil, reject(CodeSidecarTooLarge, "%d bytes exceeds %d", len(b), MaxSidecarBytes) + } + + // Row 2. json.Valid rejects trailing non-whitespace, a BOM and truncation; + // invalid UTF-8 is checked separately because encoding/json would silently + // substitute U+FFFD rather than refuse it. + if !utf8.Valid(b) || !json.Valid(b) { + return nil, reject(CodeSidecarMalformed, "not a single valid JSON value") + } + if t := bytes.TrimLeft(b, " \t\r\n"); len(t) == 0 || t[0] != '{' { + return nil, reject(CodeSidecarMalformed, "not a JSON object") + } + + fields, err := scanObject(b) + if err != nil { + return nil, err + } + + // Row 3. + for _, k := range sidecarKeys { + if len(fields[k]) == 0 { + return nil, reject(CodeSidecarMissingField, "missing %q", k) + } + } + // Row 4. Keys are compared exactly: encoding/json's case-insensitive + // struct matching would let "Algorithm" through. + for k := range fields { + if !isSidecarKey(k) { + return nil, reject(CodeSidecarUnknownField, "unknown key %q", k) + } + } + // Row 5. encoding/json would silently keep the last duplicate. + for _, k := range sidecarKeys { + if len(fields[k]) > 1 { + return nil, reject(CodeSidecarDuplicateField, "key %q appears %d times", k, len(fields[k])) + } + } + // Row 6. + var vals [len(sidecarKeys)]string + for i, k := range sidecarKeys { + raw := fields[k][0] + if len(raw) == 0 || raw[0] != '"' { + return nil, reject(CodeSidecarType, "%q must be a JSON string", k) + } + if err := json.Unmarshal(raw, &vals[i]); err != nil { + return nil, reject(CodeSidecarType, "%q must be a JSON string", k) + } + } + version, algorithm, keyID, sigB64 := vals[0], vals[1], vals[2], vals[3] + + // Rows 7..9. + if version != sidecarVersion { + return nil, reject(CodeSidecarVersionUnsupported, "sidecar_version %q", version) + } + // An unknown algorithm is a hard rejection, never a fallback or a skip. + if algorithm != sidecarAlgorithm { + return nil, reject(CodeSidecarAlgorithmUnsupported, "algorithm %q", algorithm) + } + if !isKeyID(keyID) { + return nil, reject(CodeSidecarKeyIDMalformed, "key_id must be 64 lowercase hex characters") + } + + // Row 10. + sig, err := decodeSignature(sigB64) + if err != nil { + return nil, err + } + return &Sidecar{Version: version, Algorithm: algorithm, KeyID: keyID, Signature: sig}, nil +} + +// scanObject walks the top-level object token by token and returns every raw +// value per key, preserving duplicates, which json.Unmarshal into a map or +// struct cannot do. The input has already passed json.Valid and starts with +// '{', so a token error here is unexpected and is reported as malformed. +func scanObject(b []byte) (map[string][]json.RawMessage, error) { + dec := json.NewDecoder(bytes.NewReader(b)) + if tok, err := dec.Token(); err != nil || tok != json.Delim('{') { + return nil, reject(CodeSidecarMalformed, "not a JSON object") + } + fields := make(map[string][]json.RawMessage) + for dec.More() { + tok, err := dec.Token() + if err != nil { + return nil, reject(CodeSidecarMalformed, "%v", err) + } + key, ok := tok.(string) + if !ok { + return nil, reject(CodeSidecarMalformed, "non-string object key") + } + var raw json.RawMessage + if err := dec.Decode(&raw); err != nil { + return nil, reject(CodeSidecarMalformed, "%v", err) + } + fields[key] = append(fields[key], raw) + } + return fields, nil +} + +func isSidecarKey(k string) bool { + for _, want := range sidecarKeys { + if k == want { + return true + } + } + return false +} + +// isKeyID reports whether s is exactly 64 lowercase hex characters. +func isKeyID(s string) bool { + if len(s) != 64 { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} + +// decodeSignature enforces contract row 10. base64.StdEncoding is lenient in +// two ways that break "two implementations reject identically": it ignores +// embedded newlines and it accepts non-canonical trailing bits. So the value +// must be exactly 88 chars, decode under Strict(), re-encode to the same +// bytes, and decode to exactly 64 bytes. The length rule is what catches an +// embedded newline that Strict() would skip. +func decodeSignature(s string) ([]byte, error) { + if len(s) != signatureB64Len { + return nil, reject(CodeSidecarSignatureMalformed, "signature is %d chars, want %d", len(s), signatureB64Len) + } + sig, err := base64.StdEncoding.Strict().DecodeString(s) + if err != nil { + return nil, reject(CodeSidecarSignatureMalformed, "signature is not strict standard base64") + } + if base64.StdEncoding.EncodeToString(sig) != s { + return nil, reject(CodeSidecarSignatureMalformed, "signature is not canonical base64") + } + if len(sig) != ed25519.SignatureSize { + return nil, reject(CodeSidecarSignatureMalformed, "signature decodes to %d bytes, want %d", len(sig), ed25519.SignatureSize) + } + return sig, nil +} diff --git a/internal/security/bundlesig/sidecar_test.go b/internal/security/bundlesig/sidecar_test.go new file mode 100644 index 000000000..d00450303 --- /dev/null +++ b/internal/security/bundlesig/sidecar_test.go @@ -0,0 +1,227 @@ +package bundlesig + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "errors" + "strings" + "testing" +) + +func wantCode(t *testing.T, err error, code string) { + t.Helper() + if err == nil { + t.Fatalf("want rejection %q, got nil error", code) + } + if got := CodeOf(err); got != code { + t.Fatalf("want code %q, got %q (%v)", code, got, err) + } +} + +func TestParseSidecarValid(t *testing.T) { + a := testKeyA() + for _, name := range []string{"valid.json.sig", "accept-trailing-newline.sig", "accept-4096-bytes.sig", "accept-all-zero-signature.sig"} { + t.Run(name, func(t *testing.T) { + sc, err := ParseSidecar(readFixture(t, name)) + if err != nil { + t.Fatalf("ParseSidecar: %v", err) + } + if sc.Version != "1" || sc.Algorithm != "ed25519" { + t.Fatalf("version/algorithm = %q/%q", sc.Version, sc.Algorithm) + } + if sc.KeyID != KeyID(pubOf(a)) { + t.Fatalf("key id = %q", sc.KeyID) + } + if len(sc.Signature) != ed25519.SignatureSize { + t.Fatalf("signature len = %d", len(sc.Signature)) + } + }) + } + sc, err := ParseSidecar(readFixture(t, "accept-all-zero-signature.sig")) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(sc.Signature, make([]byte, 64)) { + t.Fatal("all-zero fixture did not decode to 64 zero bytes") + } +} + +// TestParseSidecarRejectFixtures pins one fixture per contract row 1-10; the +// assertion is on the reason code, never merely "it failed". +func TestParseSidecarRejectFixtures(t *testing.T) { + cases := []string{ + CodeSidecarTooLarge, CodeSidecarMalformed, CodeSidecarMissingField, + CodeSidecarUnknownField, CodeSidecarDuplicateField, CodeSidecarType, + CodeSidecarVersionUnsupported, CodeSidecarAlgorithmUnsupported, + CodeSidecarKeyIDMalformed, CodeSidecarSignatureMalformed, + } + for _, code := range cases { + t.Run(code, func(t *testing.T) { + _, err := ParseSidecar(readFixture(t, "reject-"+code+".sig")) + wantCode(t, err, code) + }) + } +} + +func TestParseSidecarSizeBoundary(t *testing.T) { + good := readFixture(t, "valid.json.sig") + at := append(append([]byte{}, good...), bytes.Repeat([]byte("\n"), MaxSidecarBytes-len(good))...) + if len(at) != 4096 { + t.Fatalf("setup: len = %d", len(at)) + } + if _, err := ParseSidecar(at); err != nil { + t.Fatalf("exactly 4096 bytes must parse: %v", err) + } + _, err := ParseSidecar(append(at, ' ')) + wantCode(t, err, CodeSidecarTooLarge) + // The cap is checked before parsing: 4097 bytes of garbage is too_large, + // not malformed. + _, err = ParseSidecar(bytes.Repeat([]byte("x"), MaxSidecarBytes+1)) + wantCode(t, err, CodeSidecarTooLarge) +} + +// TestParseSidecarRowPrecedence: rows are evaluated in numerical order and the +// first match wins, so overlapping conditions report one stable reason. +func TestParseSidecarRowPrecedence(t *testing.T) { + id := KeyID(pubOf(testKeyA())) + cases := []struct { + name string + in string + code string + }{ + {"unknown and missing -> missing (3 before 4)", `{"foo":1}`, CodeSidecarMissingField}, + {"empty object -> missing", `{}`, CodeSidecarMissingField}, + {"case-different key is a different key", `{"sidecar_version":"1","Algorithm":"ed25519","key_id":"` + id + `","signature":"` + zeroSig + `"}`, CodeSidecarMissingField}, + {"duplicate and unknown -> unknown (4 before 5)", `{"sidecar_version":"1","algorithm":"ed25519","algorithm":"ed25519","key_id":"` + id + `","signature":"` + zeroSig + `","x":1}`, CodeSidecarUnknownField}, + {"duplicate and wrong type -> duplicate (5 before 6)", `{"sidecar_version":"1","algorithm":"ed25519","algorithm":7,"key_id":"` + id + `","signature":"` + zeroSig + `"}`, CodeSidecarDuplicateField}, + {"escaped spelling of a key still duplicates", `{"sidecar_version":"1","algorithm":"ed25519","algorithm":"rsa","key_id":"` + id + `","signature":"` + zeroSig + `"}`, CodeSidecarDuplicateField}, + {"wrong type and bad version -> type (6 before 7)", `{"sidecar_version":"9","algorithm":7,"key_id":"` + id + `","signature":"` + zeroSig + `"}`, CodeSidecarType}, + {"null value is a type error", `{"sidecar_version":null,"algorithm":"ed25519","key_id":"` + id + `","signature":"` + zeroSig + `"}`, CodeSidecarType}, + {"object value is a type error", `{"sidecar_version":"1","algorithm":{},"key_id":"` + id + `","signature":"` + zeroSig + `"}`, CodeSidecarType}, + {"bad version and bad algorithm -> version (7 before 8)", `{"sidecar_version":"2","algorithm":"rsa","key_id":"` + id + `","signature":"` + zeroSig + `"}`, CodeSidecarVersionUnsupported}, + {"bad algorithm and bad key id -> algorithm (8 before 9)", `{"sidecar_version":"1","algorithm":"rsa","key_id":"zz","signature":"` + zeroSig + `"}`, CodeSidecarAlgorithmUnsupported}, + {"bad key id and bad signature -> key id (9 before 10)", `{"sidecar_version":"1","algorithm":"ed25519","key_id":"zz","signature":"x"}`, CodeSidecarKeyIDMalformed}, + {"empty input", ``, CodeSidecarMalformed}, + {"whitespace only", " \n", CodeSidecarMalformed}, + {"array", `[]`, CodeSidecarMalformed}, + {"null", `null`, CodeSidecarMalformed}, + {"two objects", `{"a":1}{"b":2}`, CodeSidecarMalformed}, + {"truncated", `{"sidecar_version":"1"`, CodeSidecarMalformed}, + {"BOM", "\xef\xbb\xbf" + `{"sidecar_version":"1","algorithm":"ed25519","key_id":"` + id + `","signature":"` + zeroSig + `"}`, CodeSidecarMalformed}, + {"invalid UTF-8", `{"sidecar_version":"1","algorithm":"ed25519","key_id":"` + id + `","signature":"` + zeroSig + "\xff" + `"}`, CodeSidecarMalformed}, + {"trailing comma", `{"sidecar_version":"1","algorithm":"ed25519","key_id":"` + id + `","signature":"` + zeroSig + `",}`, CodeSidecarMalformed}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := ParseSidecar([]byte(tc.in)) + wantCode(t, err, tc.code) + }) + } +} + +func TestParseSidecarKeyIDGrammar(t *testing.T) { + good := KeyID(pubOf(testKeyA())) + bad := map[string]string{ + "uppercase": strings.ToUpper(good), + "63 chars": good[:63], + "65 chars": good + "0", + "non-hex": "g" + good[1:], + "empty": "", + "whitespace": " " + good[1:], + } + for name, id := range bad { + t.Run(name, func(t *testing.T) { + _, err := ParseSidecar(strSidecar("1", "ed25519", id, zeroSig)) + wantCode(t, err, CodeSidecarKeyIDMalformed) + }) + } +} + +func TestParseSidecarSignatureEncoding(t *testing.T) { + id := KeyID(pubOf(testKeyA())) + rawSig := make([]byte, 64) + for i := range rawSig { + rawSig[i] = 0xfb // encodes to '-'/'_' under the URL alphabet + } + urlSig := base64.URLEncoding.EncodeToString(rawSig) + if !strings.ContainsAny(urlSig, "-_") || len(urlSig) != 88 { + t.Fatalf("setup: urlSig = %q", urlSig) + } + + // 64 zero bytes end in "AA=="; the second-to-last char carries 4 padding + // bits that must be zero. "AB==" decodes leniently but is not canonical. + nonCanonical := zeroSig[:85] + "B==" // 85 + 3 = 88 + if len(nonCanonical) != 88 { + t.Fatalf("setup: len = %d", len(nonCanonical)) + } + if _, err := base64.StdEncoding.DecodeString(nonCanonical); err != nil { + t.Fatalf("setup: lenient decoder should accept the non-canonical form: %v", err) + } + + cases := map[string]string{ + "non-canonical trailing bits": nonCanonical, + "87 chars": zeroSig[:87], + "89 chars": zeroSig + "A", + "url alphabet": urlSig, + "missing padding (86 chars)": base64.RawStdEncoding.EncodeToString(make([]byte, 64)), + "empty": "", + "one char": "A", + // 88 chars, but a newline replaces a data char: Strict() skips the + // newline, so only the decoded length / round trip can catch it. + "embedded newline (88 chars)": zeroSig[:10] + `\n` + zeroSig[11:], + // Strict() ACCEPTS this (it skips the newline and decodes all 64 + // bytes); only the exact-88-chars rule rejects it. + "embedded newline (89 chars, strict-decodable)": zeroSig[:10] + `\n` + zeroSig[10:], + "embedded carriage return": zeroSig[:20] + `\r` + zeroSig[21:], + "spaces": strings.Repeat(" ", 88), + } + for name, sig := range cases { + t.Run(name, func(t *testing.T) { + _, err := ParseSidecar(rawSidecar(`"1"`, `"ed25519"`, quote(id), quote(sig))) + wantCode(t, err, CodeSidecarSignatureMalformed) + }) + } + + sc, err := ParseSidecar(rawSidecar(`"1"`, `"ed25519"`, quote(id), quote(zeroSig))) + if err != nil || len(sc.Signature) != 64 { + t.Fatalf("control: %v", err) + } +} + +func TestParseSidecarWhitespaceAndOrderTolerated(t *testing.T) { + a := testKeyA() + bundle := readFixture(t, "valid.json") + good, err := ParseSidecar(readFixture(t, "valid.json.sig")) + if err != nil { + t.Fatal(err) + } + sigB64 := base64.StdEncoding.EncodeToString(good.Signature) + variant := "\n {\n\t\"signature\" : \"" + sigB64 + "\",\n \"key_id\":\"" + good.KeyID + + "\" ,\"algorithm\":\"ed25519\", \"sidecar_version\": \"1\"\n} \r\n" + sc, err := ParseSidecar([]byte(variant)) + if err != nil { + t.Fatalf("reordered/whitespace variant must parse: %v", err) + } + ts, err := NewTrustSet(Key{ID: KeyID(pubOf(a)), Public: pubOf(a), Authority: "operator"}) + if err != nil { + t.Fatal(err) + } + if _, err := ts.Verify(bundle, sc); err != nil { + t.Fatalf("variant must still verify (SC-001 non-requirement): %v", err) + } +} + +func TestErrorAndCodeOf(t *testing.T) { + e := &Error{Code: CodeSignatureInvalid, Detail: "boom"} + if !strings.Contains(e.Error(), CodeSignatureInvalid) || !strings.Contains(e.Error(), "boom") { + t.Fatalf("Error() = %q", e.Error()) + } + wrapped := errors.Join(errors.New("ctx"), e) + if CodeOf(wrapped) != CodeSignatureInvalid { + t.Fatal("CodeOf must see through wrapping") + } + if CodeOf(errors.New("plain")) != "" || CodeOf(nil) != "" { + t.Fatal("CodeOf of non-bundlesig error must be empty") + } +} diff --git a/internal/security/bundlesig/testdata/accept-4096-bytes.sig b/internal/security/bundlesig/testdata/accept-4096-bytes.sig new file mode 100644 index 000000000..c954ebb15 --- /dev/null +++ b/internal/security/bundlesig/testdata/accept-4096-bytes.sig @@ -0,0 +1 @@ + {"sidecar_version":"1","algorithm":"ed25519","key_id":"34750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e","signature":"nQEAQop0DogjB6Wj+e5UHqdhOjBLrx53icQhbLI/iafdAuA2r9ErvuPb5vuzLuogHerZNK94Ezt7H+zI+lMuCQ=="} diff --git a/internal/security/bundlesig/testdata/accept-all-zero-signature.sig b/internal/security/bundlesig/testdata/accept-all-zero-signature.sig new file mode 100644 index 000000000..cc1bd66dc --- /dev/null +++ b/internal/security/bundlesig/testdata/accept-all-zero-signature.sig @@ -0,0 +1 @@ +{"sidecar_version":"1","algorithm":"ed25519","key_id":"34750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e","signature":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="} diff --git a/internal/security/bundlesig/testdata/accept-trailing-newline.sig b/internal/security/bundlesig/testdata/accept-trailing-newline.sig new file mode 100644 index 000000000..794c0c48a --- /dev/null +++ b/internal/security/bundlesig/testdata/accept-trailing-newline.sig @@ -0,0 +1 @@ +{"sidecar_version":"1","algorithm":"ed25519","key_id":"34750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e","signature":"nQEAQop0DogjB6Wj+e5UHqdhOjBLrx53icQhbLI/iafdAuA2r9ErvuPb5vuzLuogHerZNK94Ezt7H+zI+lMuCQ=="} diff --git a/internal/security/bundlesig/testdata/reject-key_id_mismatch.bundle.json b/internal/security/bundlesig/testdata/reject-key_id_mismatch.bundle.json new file mode 100644 index 000000000..302ddfa44 --- /dev/null +++ b/internal/security/bundlesig/testdata/reject-key_id_mismatch.bundle.json @@ -0,0 +1 @@ +{"sequence":7,"key_id":"6a3803d5f059902a1c6dafbc9ba4729212f7caac08634cc3ae76b27529f03827","trust_epoch":1,"generated_at":"2026-10-20T00:00:00Z","rules":[]} diff --git a/internal/security/bundlesig/testdata/reject-key_id_mismatch.sig b/internal/security/bundlesig/testdata/reject-key_id_mismatch.sig new file mode 100644 index 000000000..32102b97a --- /dev/null +++ b/internal/security/bundlesig/testdata/reject-key_id_mismatch.sig @@ -0,0 +1 @@ +{"sidecar_version":"1","algorithm":"ed25519","key_id":"34750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e","signature":"7pCjJ2ofdiFYAu+G5cS0KmjZT3mEzOFXJfVyGsicZ1f/KFuDne75I4clWnLXl9UPV9hAJQKREuaH1g1qenG9DA=="} diff --git a/internal/security/bundlesig/testdata/reject-key_not_trusted.sig b/internal/security/bundlesig/testdata/reject-key_not_trusted.sig new file mode 100644 index 000000000..d9f7de33b --- /dev/null +++ b/internal/security/bundlesig/testdata/reject-key_not_trusted.sig @@ -0,0 +1 @@ +{"sidecar_version":"1","algorithm":"ed25519","key_id":"6a3803d5f059902a1c6dafbc9ba4729212f7caac08634cc3ae76b27529f03827","signature":"i6DIAkJGHFEY/vNVNOD3674Z5v2ASDlZcRpRAGIl8klAKhLTXgTJrZOwmCcXidNm+6ehkNNSYe9Nuv+0nu1ACQ=="} diff --git a/internal/security/bundlesig/testdata/reject-sidecar_algorithm_unsupported.sig b/internal/security/bundlesig/testdata/reject-sidecar_algorithm_unsupported.sig new file mode 100644 index 000000000..95efa98e3 --- /dev/null +++ b/internal/security/bundlesig/testdata/reject-sidecar_algorithm_unsupported.sig @@ -0,0 +1 @@ +{"sidecar_version":"1","algorithm":"rsa","key_id":"34750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e","signature":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="} diff --git a/internal/security/bundlesig/testdata/reject-sidecar_duplicate_field.sig b/internal/security/bundlesig/testdata/reject-sidecar_duplicate_field.sig new file mode 100644 index 000000000..52cf815c2 --- /dev/null +++ b/internal/security/bundlesig/testdata/reject-sidecar_duplicate_field.sig @@ -0,0 +1 @@ +{"sidecar_version":"1","algorithm":"ed25519","algorithm":"rsa","key_id":"34750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e","signature":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="} diff --git a/internal/security/bundlesig/testdata/reject-sidecar_key_id_malformed.sig b/internal/security/bundlesig/testdata/reject-sidecar_key_id_malformed.sig new file mode 100644 index 000000000..dbd1f941f --- /dev/null +++ b/internal/security/bundlesig/testdata/reject-sidecar_key_id_malformed.sig @@ -0,0 +1 @@ +{"sidecar_version":"1","algorithm":"ed25519","key_id":"ABCDEF","signature":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="} diff --git a/internal/security/bundlesig/testdata/reject-sidecar_malformed.sig b/internal/security/bundlesig/testdata/reject-sidecar_malformed.sig new file mode 100644 index 000000000..13124d657 --- /dev/null +++ b/internal/security/bundlesig/testdata/reject-sidecar_malformed.sig @@ -0,0 +1 @@ +{"sidecar_version":"1","algorithm":"ed25519","key_id":"34750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e","signature":"nQEAQop0DogjB6Wj+e5UHqdhOjBLrx53icQhbLI/iafdAuA2r9ErvuPb5vuzLuogHerZNK94Ezt7H+zI+lMuCQ=="}x diff --git a/internal/security/bundlesig/testdata/reject-sidecar_missing_field.sig b/internal/security/bundlesig/testdata/reject-sidecar_missing_field.sig new file mode 100644 index 000000000..ee2b3d38a --- /dev/null +++ b/internal/security/bundlesig/testdata/reject-sidecar_missing_field.sig @@ -0,0 +1 @@ +{"sidecar_version":"1","algorithm":"ed25519","key_id":"34750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e"} diff --git a/internal/security/bundlesig/testdata/reject-sidecar_signature_malformed.sig b/internal/security/bundlesig/testdata/reject-sidecar_signature_malformed.sig new file mode 100644 index 000000000..2c1ab41c7 --- /dev/null +++ b/internal/security/bundlesig/testdata/reject-sidecar_signature_malformed.sig @@ -0,0 +1 @@ +{"sidecar_version":"1","algorithm":"ed25519","key_id":"34750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e","signature":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="} diff --git a/internal/security/bundlesig/testdata/reject-sidecar_too_large.sig b/internal/security/bundlesig/testdata/reject-sidecar_too_large.sig new file mode 100644 index 000000000..bd861ea46 --- /dev/null +++ b/internal/security/bundlesig/testdata/reject-sidecar_too_large.sig @@ -0,0 +1 @@ + {"sidecar_version":"1","algorithm":"ed25519","key_id":"34750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e","signature":"nQEAQop0DogjB6Wj+e5UHqdhOjBLrx53icQhbLI/iafdAuA2r9ErvuPb5vuzLuogHerZNK94Ezt7H+zI+lMuCQ=="} diff --git a/internal/security/bundlesig/testdata/reject-sidecar_type.sig b/internal/security/bundlesig/testdata/reject-sidecar_type.sig new file mode 100644 index 000000000..a2596224b --- /dev/null +++ b/internal/security/bundlesig/testdata/reject-sidecar_type.sig @@ -0,0 +1 @@ +{"sidecar_version":1,"algorithm":"ed25519","key_id":"34750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e","signature":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="} diff --git a/internal/security/bundlesig/testdata/reject-sidecar_unknown_field.sig b/internal/security/bundlesig/testdata/reject-sidecar_unknown_field.sig new file mode 100644 index 000000000..712edab4b --- /dev/null +++ b/internal/security/bundlesig/testdata/reject-sidecar_unknown_field.sig @@ -0,0 +1 @@ +{"sidecar_version":"1","algorithm":"ed25519","key_id":"34750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e","signature":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==","sequence":"7"} diff --git a/internal/security/bundlesig/testdata/reject-sidecar_version_unsupported.sig b/internal/security/bundlesig/testdata/reject-sidecar_version_unsupported.sig new file mode 100644 index 000000000..2527032d0 --- /dev/null +++ b/internal/security/bundlesig/testdata/reject-sidecar_version_unsupported.sig @@ -0,0 +1 @@ +{"sidecar_version":"2","algorithm":"ed25519","key_id":"34750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e","signature":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="} diff --git a/internal/security/bundlesig/testdata/reject-signature_invalid.sig b/internal/security/bundlesig/testdata/reject-signature_invalid.sig new file mode 100644 index 000000000..cc1bd66dc --- /dev/null +++ b/internal/security/bundlesig/testdata/reject-signature_invalid.sig @@ -0,0 +1 @@ +{"sidecar_version":"1","algorithm":"ed25519","key_id":"34750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e","signature":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="} diff --git a/internal/security/bundlesig/testdata/valid.json b/internal/security/bundlesig/testdata/valid.json new file mode 100644 index 000000000..f67857e78 --- /dev/null +++ b/internal/security/bundlesig/testdata/valid.json @@ -0,0 +1 @@ +{"sequence":7,"key_id":"34750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e","trust_epoch":1,"generated_at":"2026-10-20T00:00:00Z","rules":[]} diff --git a/internal/security/bundlesig/testdata/valid.json.sig b/internal/security/bundlesig/testdata/valid.json.sig new file mode 100644 index 000000000..bc7ac9743 --- /dev/null +++ b/internal/security/bundlesig/testdata/valid.json.sig @@ -0,0 +1 @@ +{"sidecar_version":"1","algorithm":"ed25519","key_id":"34750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e","signature":"nQEAQop0DogjB6Wj+e5UHqdhOjBLrx53icQhbLI/iafdAuA2r9ErvuPb5vuzLuogHerZNK94Ezt7H+zI+lMuCQ=="} diff --git a/internal/security/bundlesig/verify.go b/internal/security/bundlesig/verify.go new file mode 100644 index 000000000..ad2f8b869 --- /dev/null +++ b/internal/security/bundlesig/verify.go @@ -0,0 +1,119 @@ +package bundlesig + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" +) + +// KeyID returns the fingerprint of an Ed25519 public key: the lowercase hex +// SHA-256 of its 32 raw bytes. It is a property of the key itself, never an +// operator-chosen label, which is what makes "manifest names one publisher, +// bytes signed by another" detectable. +func KeyID(pub ed25519.PublicKey) string { + sum := sha256.Sum256(pub) + return hex.EncodeToString(sum[:]) +} + +// ParsePublicKey decodes a configured signer: standard padded base64 of +// exactly 32 bytes. Like the sidecar signature it must be strict and +// canonical, so a trust anchor cannot be spelled two ways. +func ParsePublicKey(s string) (ed25519.PublicKey, error) { + raw, err := base64.StdEncoding.Strict().DecodeString(s) + if err != nil { + return nil, fmt.Errorf("public key is not strict standard base64: %w", err) + } + if base64.StdEncoding.EncodeToString(raw) != s { + return nil, errors.New("public key is not canonical base64") + } + if len(raw) != ed25519.PublicKeySize { + return nil, fmt.Errorf("public key is %d bytes, want %d", len(raw), ed25519.PublicKeySize) + } + return ed25519.PublicKey(raw), nil +} + +// Key is one trusted signer. Authority names the root it belongs to; this +// package stores it and hands it back but attaches no meaning to it. +type Key struct { + ID string + Public ed25519.PublicKey + Authority string +} + +// TrustSet is an immutable set of trusted keys indexed by key id. +type TrustSet struct { + keys map[string]Key +} + +// NewTrustSet validates and indexes keys. It refuses a wrong-length key, a key +// whose ID is not the fingerprint of its Public bytes, and a duplicate id even +// across authorities: a single key must not be able to satisfy whichever root +// happens to be active. It copies the key bytes, so later mutation of the +// caller's slices cannot change what verifies. +func NewTrustSet(keys ...Key) (*TrustSet, error) { + ts := &TrustSet{keys: make(map[string]Key, len(keys))} + for _, k := range keys { + if len(k.Public) != ed25519.PublicKeySize { + return nil, fmt.Errorf("trust key %q: public key is %d bytes, want %d", k.ID, len(k.Public), ed25519.PublicKeySize) + } + if want := KeyID(k.Public); k.ID != want { + return nil, fmt.Errorf("trust key id %q is not the fingerprint of its public key (%s)", k.ID, want) + } + if prev, dup := ts.keys[k.ID]; dup { + return nil, fmt.Errorf("trust key %s listed twice (authorities %q and %q)", k.ID, prev.Authority, k.Authority) + } + k.Public = append(ed25519.PublicKey(nil), k.Public...) + ts.keys[k.ID] = k + } + return ts, nil +} + +// Lookup returns the key with the given id. The returned Public is a copy. +func (t *TrustSet) Lookup(id string) (Key, bool) { + k, ok := t.keys[id] + if ok { + k.Public = append(ed25519.PublicKey(nil), k.Public...) + } + return k, ok +} + +// Verify checks sc against bundle: row 11 (the sidecar's key id must be known +// to the WHOLE set) then row 12 (Ed25519 over the exact raw bundle bytes). On +// success it returns the verifying key. +// +// Verify deliberately does not decide which authority is active. A genuine key +// of the non-active root must verify here so the caller can report +// authority_not_active rather than a misleading key_not_trusted. +func (t *TrustSet) Verify(bundle []byte, sc *Sidecar) (Key, error) { + k, ok := t.Lookup(sc.KeyID) + if !ok { + return Key{}, reject(CodeKeyNotTrusted, "key %s is not in the trust set", sc.KeyID) + } + if !ed25519.Verify(k.Public, bundle, sc.Signature) { + return Key{}, reject(CodeSignatureInvalid, "signature does not verify under key %s", sc.KeyID) + } + return k, nil +} + +// CheckKeyIDAgreement enforces row 13: the manifest's key_id, the sidecar's +// key_id and the verifying key's id must all be identical. An empty manifest +// key id is a mismatch, never "unspecified". +func CheckKeyIDAgreement(manifestKeyID string, sc *Sidecar, verified Key) error { + if manifestKeyID == "" || manifestKeyID != sc.KeyID || sc.KeyID != verified.ID { + return reject(CodeKeyIDMismatch, "manifest %q, sidecar %q, verifying key %q", manifestKeyID, sc.KeyID, verified.ID) + } + return nil +} + +// SignSidecar returns the canonical sidecar for bundle: one line, keys in the +// order sidecar_version, algorithm, key_id, signature, no trailing newline. +// It exists for publishers and tests; the loader never signs anything. +func SignSidecar(priv ed25519.PrivateKey, bundle []byte) []byte { + pub := priv.Public().(ed25519.PublicKey) + sig := base64.StdEncoding.EncodeToString(ed25519.Sign(priv, bundle)) + return []byte(`{"sidecar_version":"` + sidecarVersion + `","algorithm":"` + sidecarAlgorithm + + `","key_id":"` + KeyID(pub) + `","signature":"` + sig + `"}`) +} diff --git a/internal/security/bundlesig/verify_test.go b/internal/security/bundlesig/verify_test.go new file mode 100644 index 000000000..2d1ecaa6a --- /dev/null +++ b/internal/security/bundlesig/verify_test.go @@ -0,0 +1,317 @@ +package bundlesig + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "strings" + "testing" +) + +func trustA(t *testing.T) *TrustSet { + t.Helper() + a := testKeyA() + ts, err := NewTrustSet(Key{ID: KeyID(pubOf(a)), Public: pubOf(a), Authority: "operator"}) + if err != nil { + t.Fatal(err) + } + return ts +} + +func TestKeyID(t *testing.T) { + pub := pubOf(testKeyA()) + sum := sha256.Sum256(pub) + if got, want := KeyID(pub), hex.EncodeToString(sum[:]); got != want { + t.Fatalf("KeyID = %q want %q", got, want) + } + if id := KeyID(pub); len(id) != 64 || id != strings.ToLower(id) { + t.Fatal("key id must be 64 lowercase hex") + } +} + +func TestParsePublicKey(t *testing.T) { + pub := pubOf(testKeyA()) + enc := base64.StdEncoding.EncodeToString(pub) + got, err := ParsePublicKey(enc) + if err != nil || !bytes.Equal(got, pub) { + t.Fatalf("round trip: %v", err) + } + bad := map[string]string{ + "empty": "", + "31 bytes": base64.StdEncoding.EncodeToString(pub[:31]), + "33 bytes": base64.StdEncoding.EncodeToString(append(append([]byte{}, pub...), 0)), + "url alphabet": base64.URLEncoding.EncodeToString(bytes.Repeat([]byte{0xfb}, 32)), + "raw (no pad)": base64.RawStdEncoding.EncodeToString(pub), + "newline": enc[:10] + "\n" + enc[10:], + "trailing junk": enc + "!", + // 32 bytes -> 43 data chars + "="; "B=" last char has nonzero padding bits. + "non-canonical": base64.StdEncoding.EncodeToString(make([]byte, 32))[:42] + "B=", + } + for name, s := range bad { + t.Run(name, func(t *testing.T) { + if _, err := ParsePublicKey(s); err == nil { + t.Fatalf("ParsePublicKey(%q) accepted", s) + } + }) + } +} + +func TestNewTrustSet(t *testing.T) { + a, b := testKeyA(), testKeyB() + ka := Key{ID: KeyID(pubOf(a)), Public: pubOf(a), Authority: "operator"} + kb := Key{ID: KeyID(pubOf(b)), Public: pubOf(b), Authority: "baseline"} + + ts, err := NewTrustSet(ka, kb) + if err != nil { + t.Fatal(err) + } + for _, k := range []Key{ka, kb} { + got, ok := ts.Lookup(k.ID) + if !ok || got.Authority != k.Authority || !bytes.Equal(got.Public, k.Public) { + t.Fatalf("Lookup(%s) = %+v, %v", k.ID, got, ok) + } + } + if _, ok := ts.Lookup(KeyID(ed25519.PublicKey(make([]byte, 32)))); ok { + t.Fatal("unknown id found") + } + + // A duplicate id is refused even across authorities: otherwise one key + // could satisfy whichever root happens to be active. + if _, err := NewTrustSet(ka, Key{ID: ka.ID, Public: ka.Public, Authority: "baseline"}); err == nil { + t.Fatal("cross-authority duplicate id accepted") + } + if _, err := NewTrustSet(ka, ka); err == nil { + t.Fatal("same-authority duplicate id accepted") + } + if _, err := NewTrustSet(Key{ID: KeyID(pubOf(b)), Public: pubOf(a), Authority: "operator"}); err == nil { + t.Fatal("id that is not the fingerprint of Public accepted") + } + if _, err := NewTrustSet(Key{ID: "x", Public: ed25519.PublicKey(make([]byte, 31))}); err == nil { + t.Fatal("wrong-length key accepted") + } + if _, err := NewTrustSet(Key{ID: KeyID(nil), Public: nil}); err == nil { + t.Fatal("nil key accepted") + } + + // The set owns its key bytes: mutating the caller's slice afterwards must + // not change what verifies. + mutable := append(ed25519.PublicKey{}, pubOf(a)...) + ts2, err := NewTrustSet(Key{ID: ka.ID, Public: mutable, Authority: "operator"}) + if err != nil { + t.Fatal(err) + } + mutable[0] ^= 0xff + got, _ := ts2.Lookup(ka.ID) + if !bytes.Equal(got.Public, pubOf(a)) { + t.Fatal("trust set aliases caller's key bytes") + } +} + +func TestVerifyValidFixture(t *testing.T) { + sc, err := ParseSidecar(readFixture(t, "valid.json.sig")) + if err != nil { + t.Fatal(err) + } + k, err := trustA(t).Verify(readFixture(t, "valid.json"), sc) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if k.ID != sc.KeyID || k.Authority != "operator" { + t.Fatalf("returned key = %+v", k) + } +} + +func TestVerifyRejectFixtures(t *testing.T) { + valid := readFixture(t, "valid.json") + for _, tc := range []struct{ fixture, code string }{ + {"reject-key_not_trusted.sig", CodeKeyNotTrusted}, + {"reject-signature_invalid.sig", CodeSignatureInvalid}, + {"accept-all-zero-signature.sig", CodeSignatureInvalid}, // parses, never verifies + } { + t.Run(tc.fixture, func(t *testing.T) { + sc, err := ParseSidecar(readFixture(t, tc.fixture)) + if err != nil { + t.Fatalf("fixture must parse: %v", err) + } + _, err = trustA(t).Verify(valid, sc) + wantCode(t, err, tc.code) + }) + } + + // Row 13: the signature is genuine, but the manifest names another key. + bundle := readFixture(t, "reject-key_id_mismatch.bundle.json") + sc, err := ParseSidecar(readFixture(t, "reject-key_id_mismatch.sig")) + if err != nil { + t.Fatal(err) + } + verified, err := trustA(t).Verify(bundle, sc) + if err != nil { + t.Fatalf("signature must verify: %v", err) + } + wantCode(t, CheckKeyIDAgreement(KeyID(pubOf(testKeyB())), sc, verified), CodeKeyIDMismatch) +} + +func TestVerifyRowOrder(t *testing.T) { + // Row 11 precedes row 12: an untrusted key with a garbage signature is + // key_not_trusted, not signature_invalid. + other := KeyID(pubOf(testKeyB())) + sc, err := ParseSidecar(strSidecar("1", "ed25519", other, zeroSig)) + if err != nil { + t.Fatal(err) + } + _, err = trustA(t).Verify(readFixture(t, "valid.json"), sc) + wantCode(t, err, CodeKeyNotTrusted) +} + +// TestVerifyDoesNotDecideActiveAuthority: Verify searches the WHOLE set, so a +// genuine key of the non-active root verifies here; the caller then rules on +// authority (-> authority_not_active upstream, not key_not_trusted). +func TestVerifyDoesNotDecideActiveAuthority(t *testing.T) { + a, b := testKeyA(), testKeyB() + ts, err := NewTrustSet( + Key{ID: KeyID(pubOf(a)), Public: pubOf(a), Authority: "operator"}, + Key{ID: KeyID(pubOf(b)), Public: pubOf(b), Authority: "baseline"}, + ) + if err != nil { + t.Fatal(err) + } + bundle := bundleFor(KeyID(pubOf(b))) + sc, err := ParseSidecar(SignSidecar(b, bundle)) + if err != nil { + t.Fatal(err) + } + k, err := ts.Verify(bundle, sc) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if k.Authority != "baseline" { + t.Fatalf("authority = %q", k.Authority) + } +} + +// TestTamperClasses covers the four SC-001 tamper classes at this layer. +func TestTamperClasses(t *testing.T) { + a := testKeyA() + bundle := readFixture(t, "valid.json") + good := readFixture(t, "valid.json.sig") + ts := trustA(t) + + verify := func(bundle, sidecar []byte) error { + sc, err := ParseSidecar(sidecar) + if err != nil { + return err + } + _, err = ts.Verify(bundle, sc) + return err + } + if err := verify(bundle, good); err != nil { + t.Fatalf("control: %v", err) + } + + t.Run("flipped bundle byte", func(t *testing.T) { + for i := range bundle { + tampered := append([]byte{}, bundle...) + tampered[i] ^= 0x01 + wantCode(t, verify(tampered, good), CodeSignatureInvalid) + } + }) + t.Run("appended and truncated bundle", func(t *testing.T) { + wantCode(t, verify(append(append([]byte{}, bundle...), ' '), good), CodeSignatureInvalid) + wantCode(t, verify(bundle[:len(bundle)-1], good), CodeSignatureInvalid) + }) + t.Run("altered signature value", func(t *testing.T) { + sc, _ := ParseSidecar(good) + sc.Signature[10] ^= 0x01 + altered := rawSidecar(`"1"`, `"ed25519"`, quote(sc.KeyID), quote(base64.StdEncoding.EncodeToString(sc.Signature))) + wantCode(t, verify(bundle, altered), CodeSignatureInvalid) + }) + t.Run("altered key_id (unknown id)", func(t *testing.T) { + sc, _ := ParseSidecar(good) + altered := strSidecar("1", "ed25519", KeyID(pubOf(testKeyB())), base64.StdEncoding.EncodeToString(sc.Signature)) + wantCode(t, verify(bundle, altered), CodeKeyNotTrusted) + }) + t.Run("altered key_id (another trusted key)", func(t *testing.T) { + // Both keys trusted; the sidecar claims B but carries A's signature: + // verification under B fails, it never falls back to A. + b := testKeyB() + both, err := NewTrustSet( + Key{ID: KeyID(pubOf(a)), Public: pubOf(a), Authority: "operator"}, + Key{ID: KeyID(pubOf(b)), Public: pubOf(b), Authority: "operator"}, + ) + if err != nil { + t.Fatal(err) + } + sc, _ := ParseSidecar(good) + sc.KeyID = KeyID(pubOf(b)) + _, err = both.Verify(bundle, sc) + wantCode(t, err, CodeSignatureInvalid) + }) + t.Run("altered manifest key_id", func(t *testing.T) { + sc, _ := ParseSidecar(good) + verified, err := ts.Verify(bundle, sc) + if err != nil { + t.Fatal(err) + } + wantCode(t, CheckKeyIDAgreement(KeyID(pubOf(testKeyB())), sc, verified), CodeKeyIDMismatch) + }) + t.Run("sidecar of bundle A against bundle B", func(t *testing.T) { + other := bundleFor(KeyID(pubOf(a))) + other = bytes.Replace(other, []byte(`"sequence":7`), []byte(`"sequence":8`), 1) + wantCode(t, verify(other, good), CodeSignatureInvalid) + }) +} + +func TestCheckKeyIDAgreement(t *testing.T) { + a := testKeyA() + id := KeyID(pubOf(a)) + sc := &Sidecar{KeyID: id} + verified := Key{ID: id, Public: pubOf(a)} + + if err := CheckKeyIDAgreement(id, sc, verified); err != nil { + t.Fatalf("all three agree: %v", err) + } + other := KeyID(pubOf(testKeyB())) + for name, args := range map[string]struct { + manifest string + sc *Sidecar + key Key + }{ + "manifest differs": {other, sc, verified}, + "sidecar differs": {id, &Sidecar{KeyID: other}, verified}, + "verified differs": {id, sc, Key{ID: other}}, + "empty manifest": {"", sc, verified}, + "all empty": {"", &Sidecar{}, Key{}}, + } { + t.Run(name, func(t *testing.T) { + wantCode(t, CheckKeyIDAgreement(args.manifest, args.sc, args.key), CodeKeyIDMismatch) + }) + } +} + +func TestSignSidecarCanonicalForm(t *testing.T) { + a := testKeyA() + bundle := []byte("any bytes at all \x00\xff") + out := SignSidecar(a, bundle) + + want := `{"sidecar_version":"1","algorithm":"ed25519","key_id":"` + KeyID(pubOf(a)) + + `","signature":"` + base64.StdEncoding.EncodeToString(ed25519.Sign(a, bundle)) + `"}` + if string(out) != want { + t.Fatalf("sidecar =\n%s\nwant\n%s", out, want) + } + if bytes.HasSuffix(out, []byte("\n")) { + t.Fatal("canonical sidecar has no trailing newline") + } + sc, err := ParseSidecar(out) + if err != nil { + t.Fatal(err) + } + if _, err := trustA(t).Verify(bundle, sc); err != nil { + t.Fatal(err) + } + // Ed25519 is deterministic, so fixtures are reproducible. + if !bytes.Equal(out, SignSidecar(a, bundle)) { + t.Fatal("SignSidecar is not deterministic") + } +} From abc8e08728c66e3943dc6066767dae308ea366cb Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 13:09:00 +0300 Subject: [PATCH 08/35] feat(security): Spec 101 US1 config trust-root validation, hot-reload compare and redaction Validate tpa_active_root and tpa_operator_signers on the write path only (boot stays up), compare the security block by its JSON form on hot-reload so a nil-vs-empty signer slice is not a change, keep tpa_operator_signers readable in GET /api/v1/config, and document the five keys. Related #938 --- docs/configuration.md | 12 ++ internal/config/config.go | 63 ++++++++ internal/config/tpa_bundle_trust_test.go | 179 ++++++++++++++++++++++ internal/oauth/configview_tpa_test.go | 57 +++++++ internal/oauth/redactview.go | 15 ++ internal/runtime/config_hotreload.go | 7 +- internal/runtime/config_hotreload_test.go | 51 ++++++ 7 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 internal/config/tpa_bundle_trust_test.go create mode 100644 internal/oauth/configview_tpa_test.go diff --git a/docs/configuration.md b/docs/configuration.md index c773b0594..f340d8474 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -931,6 +931,11 @@ block** — off by default, best-effort, and unable to change the baseline verdi "integrity_check_on_restart": false, "scanner_registry_url": "", "tpa_bundle_path": "", + "require_signed_bundle": false, + "tpa_active_root": "publisher", + "tpa_operator_signers": [], + "tpa_operator_baseline": 0, + "tpa_operator_epoch": 0, "auto_baseline_scan": true, "deep_scan": { "enabled": false, @@ -945,12 +950,19 @@ block** — off by default, best-effort, and unable to change the baseline verdi | Field | Type | Default | Description | |-------|------|---------|-------------| | `tpa_bundle_path` | string | `""` (embedded) | Filesystem path to the tpa-db `scanner-bundle.json` the offline TPA scanner runs. Empty uses the corpus embedded in the build. Env override: `MCPPROXY_TPA_BUNDLE_PATH`, which wins over this field on every path (loader, hot-reload, `/api/v1/config/apply`). Re-read on config hot-reload and honoured in every transport, stdio included. A bundle that fails to read/parse/version-check/compile — or that contributes zero runnable rules — is refused and the previously active corpus stays live; the reason is surfaced as `signature_bundle.load_error` in `GET /api/v1/security/overview` and in `mcpproxy security overview`. | +| `require_signed_bundle` | boolean | `false` | When `true`, a file-dropped TPA bundle must carry a valid detached signature; an unsigned or badly signed one is refused and the previous corpus stays live. When `false`, unsigned drops keep working but always run with degraded coverage. | +| `tpa_active_root` | string | `"publisher"` | Which signing authority may activate a bundle: `"publisher"` (the keys embedded in this binary) or `"operator"` (the keys in `tpa_operator_signers`). A bundle signed by the other authority is refused. Rejected on write for any other value, and `"operator"` needs at least one signer. | +| `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 lowest operator bundle sequence this install will accept, as an anti-downgrade floor. 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. | | `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. | | `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. | | `deep_scan.fetch_package_source` | boolean | `true` (when deep scan is on) | Whether the scanner fetches (never executes) the published source of `npx`/`uvx` package-runner servers when no local source is available. Set `false` for air-gapped deployments. | | `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. | +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. + **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 into `security.deep_scan.*` on load. The former `security.auto_scan_quarantined` diff --git a/internal/config/config.go b/internal/config/config.go index 1a94f5867..a84820f21 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "bytes" "crypto/rand" + "encoding/base64" "encoding/hex" "encoding/json" "fmt" @@ -2341,12 +2342,74 @@ func (c *Config) ValidateDetailed() []ValidationError { errors := append(c.validateDetailedCore(), c.oauthRedirectURIErrors()...) errors = append(errors, c.forwardHeadersErrors()...) errors = append(errors, c.oauthEndpointOverrideErrors()...) + errors = append(errors, c.tpaTrustRootErrors()...) if _, err := ValidateProfiles(c); err != nil { errors = append(errors, ValidationError{Field: "profiles", Message: err.Error()}) } return errors } +// tpaOperatorSignerKeyLen is the raw length of an Ed25519 public key. The config +// package checks only the shape of tpa_operator_signers; key-id derivation and +// signature verification live in internal/security/bundlesig, which config must +// not import. +const tpaOperatorSignerKeyLen = 32 + +// tpaTrustRootErrors validates the Spec 101 trust-root keys under `security`: +// tpa_active_root must be "", "publisher" or "operator"; every +// tpa_operator_signers entry must be the canonical standard base64 of exactly +// 32 bytes and unique; an "operator" root needs at least one signer. +// +// Write-time only, like oauthRedirectURIErrors: a hand-edited bad value must +// not brick boot. Load drops unusable signers (with a log) and the scanner +// treats an unusable operator root as having no operator authority, so a bad +// key can never widen what is trusted. Messages never echo the offending value. +func (c *Config) tpaTrustRootErrors() []ValidationError { + sc := c.Security + if sc == nil { + return nil + } + var errors []ValidationError + switch sc.TPAActiveRoot { + case "", TPARootPublisher, TPARootOperator: + default: + errors = append(errors, ValidationError{ + Field: "security.tpa_active_root", + Message: fmt.Sprintf("must be %q or %q", TPARootPublisher, TPARootOperator), + }) + } + + seen := make(map[string]struct{}, len(sc.TPAOperatorSigners)) + for i, signer := range sc.TPAOperatorSigners { + field := fmt.Sprintf("security.tpa_operator_signers[%d]", i) + raw, err := base64.StdEncoding.Strict().DecodeString(signer) + if err != nil || base64.StdEncoding.EncodeToString(raw) != signer { + errors = append(errors, ValidationError{Field: field, Message: "must be canonical standard base64 (with padding)"}) + continue + } + if len(raw) != tpaOperatorSignerKeyLen { + errors = append(errors, ValidationError{ + Field: field, + Message: fmt.Sprintf("must decode to exactly %d bytes (an Ed25519 public key)", tpaOperatorSignerKeyLen), + }) + continue + } + if _, dup := seen[string(raw)]; dup { + errors = append(errors, ValidationError{Field: field, Message: "duplicate signer"}) + continue + } + seen[string(raw)] = struct{}{} + } + + if sc.TPAActiveRoot == TPARootOperator && len(sc.TPAOperatorSigners) == 0 { + errors = append(errors, ValidationError{ + Field: "security.tpa_active_root", + Message: "operator root declared but no tpa_operator_signers", + }) + } + return errors +} + // ValidateServerForLoad runs the per-server checks config.Load applies at boot // (the stdio command-required check, url-required for HTTP protocols, protocol // and name validity, ...) against a single server and returns only the errors diff --git a/internal/config/tpa_bundle_trust_test.go b/internal/config/tpa_bundle_trust_test.go new file mode 100644 index 000000000..f0ec141c2 --- /dev/null +++ b/internal/config/tpa_bundle_trust_test.go @@ -0,0 +1,179 @@ +package config + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// tpaTestSigner returns the standard base64 of a deterministic 32-byte value. +// The config package only checks shape (32 raw bytes, canonical encoding); it +// never verifies signatures, so any 32 bytes stand in for a public key. +func tpaTestSigner(seed byte) string { + raw := make([]byte, 32) + for i := range raw { + raw[i] = seed + byte(i) + } + return base64.StdEncoding.EncodeToString(raw) +} + +func tpaValidConfig(sec *SecurityConfig) *Config { + cfg := DefaultConfig() + cfg.Security = sec + return cfg +} + +func tpaErrorFields(errs []ValidationError) []string { + out := []string{} + for _, e := range errs { + out = append(out, e.Field) + } + return out +} + +func TestTPAAccessors_Defaults(t *testing.T) { + sc := &SecurityConfig{} + assert.False(t, sc.IsRequireSignedBundle()) + assert.Equal(t, TPARootPublisher, sc.EffectiveTPAActiveRoot()) + assert.Empty(t, sc.EffectiveTPAOperatorSigners()) + _, ok := sc.EffectiveTPAOperatorBaseline() + assert.False(t, ok, "baseline absent by default") + assert.Equal(t, uint64(0), sc.EffectiveTPAOperatorEpoch()) +} + +func TestTPAAccessors_NilSafe(t *testing.T) { + var sc *SecurityConfig + assert.False(t, sc.IsRequireSignedBundle()) + assert.Equal(t, TPARootPublisher, sc.EffectiveTPAActiveRoot()) + assert.Empty(t, sc.EffectiveTPAOperatorSigners()) + _, ok := sc.EffectiveTPAOperatorBaseline() + assert.False(t, ok) + assert.Equal(t, uint64(0), sc.EffectiveTPAOperatorEpoch()) +} + +func TestTPAAccessors_ExplicitValues(t *testing.T) { + zero, five := uint64(0), uint64(5) + sc := &SecurityConfig{ + RequireSignedBundle: true, + TPAActiveRoot: TPARootOperator, + TPAOperatorSigners: []string{tpaTestSigner(1)}, + TPAOperatorBaseline: &zero, + TPAOperatorEpoch: &five, + } + assert.True(t, sc.IsRequireSignedBundle()) + assert.Equal(t, TPARootOperator, sc.EffectiveTPAActiveRoot()) + assert.Equal(t, []string{tpaTestSigner(1)}, sc.EffectiveTPAOperatorSigners()) + b, ok := sc.EffectiveTPAOperatorBaseline() + assert.True(t, ok, "an explicit 0 baseline is present, not absent") + assert.Equal(t, uint64(0), b) + assert.Equal(t, uint64(5), sc.EffectiveTPAOperatorEpoch()) +} + +func TestValidateDetailed_TPATrustRoot(t *testing.T) { + good1, good2 := tpaTestSigner(1), tpaTestSigner(2) + short := base64.StdEncoding.EncodeToString(make([]byte, 31)) + long := base64.StdEncoding.EncodeToString(make([]byte, 33)) + // 32 bytes encode to 43 data chars plus one '='. The last data char carries + // only 4 meaningful bits, so changing it to a different char with the same + // high bits decodes (leniently) to the same key but is not the canonical + // encoding. Strict decoding must reject it. + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + last := good1[42] + idx := strings.IndexByte(alphabet, last) + nonCanonical := good1[:42] + string(alphabet[idx^1]) + "=" + urlSafe := strings.NewReplacer("+", "-", "/", "_").Replace( + base64.StdEncoding.EncodeToString(append([]byte{0xfb, 0xff}, make([]byte, 30)...))) + require.NotContains(t, urlSafe, "+") + + tests := []struct { + name string + sec *SecurityConfig + wantFields []string + wantMsg string + }{ + {name: "nil security", sec: nil}, + {name: "empty security", sec: &SecurityConfig{}}, + {name: "publisher root", sec: &SecurityConfig{TPAActiveRoot: "publisher"}}, + {name: "operator root with signers", sec: &SecurityConfig{TPAActiveRoot: "operator", TPAOperatorSigners: []string{good1, good2}}}, + {name: "unknown root", sec: &SecurityConfig{TPAActiveRoot: "vendor"}, wantFields: []string{"security.tpa_active_root"}}, + {name: "root is case sensitive", sec: &SecurityConfig{TPAActiveRoot: "Operator", TPAOperatorSigners: []string{good1}}, wantFields: []string{"security.tpa_active_root"}}, + {name: "operator root without signers", sec: &SecurityConfig{TPAActiveRoot: "operator"}, wantFields: []string{"security.tpa_active_root"}, wantMsg: "operator root declared but no tpa_operator_signers"}, + {name: "not base64", sec: &SecurityConfig{TPAOperatorSigners: []string{good1, "not base64!"}}, wantFields: []string{"security.tpa_operator_signers[1]"}}, + {name: "too short", sec: &SecurityConfig{TPAOperatorSigners: []string{short}}, wantFields: []string{"security.tpa_operator_signers[0]"}}, + {name: "too long", sec: &SecurityConfig{TPAOperatorSigners: []string{long}}, wantFields: []string{"security.tpa_operator_signers[0]"}}, + {name: "url-safe alphabet", sec: &SecurityConfig{TPAOperatorSigners: []string{urlSafe}}, wantFields: []string{"security.tpa_operator_signers[0]"}}, + {name: "non-canonical encoding", sec: &SecurityConfig{TPAOperatorSigners: []string{nonCanonical}}, wantFields: []string{"security.tpa_operator_signers[0]"}}, + {name: "surrounding whitespace", sec: &SecurityConfig{TPAOperatorSigners: []string{" " + good1}}, wantFields: []string{"security.tpa_operator_signers[0]"}}, + {name: "duplicate signer", sec: &SecurityConfig{TPAOperatorSigners: []string{good1, good2, good1}}, wantFields: []string{"security.tpa_operator_signers[2]"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + errs := tpaValidConfig(tc.sec).ValidateDetailed() + assert.ElementsMatch(t, tc.wantFields, tpaErrorFields(errs), "errors: %v", errs) + if tc.wantMsg != "" { + require.NotEmpty(t, errs) + assert.Contains(t, errs[0].Message, tc.wantMsg) + } + }) + } +} + +// TestValidate_TPATrustRootDoesNotBrickBoot pins the write-only contract: the +// boot path (Validate -> validateDetailedCore) must keep loading a config whose +// trust-root keys are malformed. The scanner treats an unusable operator root +// as "no operator authority" and surfaces it; refusing to start would take +// every upstream server down over one key. +func TestValidate_TPATrustRootDoesNotBrickBoot(t *testing.T) { + cfg := tpaValidConfig(&SecurityConfig{ + TPAActiveRoot: "vendor", + TPAOperatorSigners: []string{"not base64!", tpaTestSigner(1), tpaTestSigner(1)}, + }) + require.NoError(t, cfg.Validate()) + assert.Empty(t, cfg.validateDetailedCore()) + assert.NotEmpty(t, cfg.ValidateDetailed(), "the same config is rejected on the write path") + + cfg = tpaValidConfig(&SecurityConfig{TPAActiveRoot: "operator"}) + require.NoError(t, cfg.Validate()) +} + +func TestSecurityConfig_TPAJSONRoundTrip(t *testing.T) { + t.Run("explicit zero survives", func(t *testing.T) { + zero := uint64(0) + in := SecurityConfig{TPAOperatorBaseline: &zero, TPAOperatorEpoch: &zero} + raw, err := json.Marshal(in) + require.NoError(t, err) + assert.Contains(t, string(raw), `"tpa_operator_baseline":0`) + assert.Contains(t, string(raw), `"tpa_operator_epoch":0`) + + var out SecurityConfig + require.NoError(t, json.Unmarshal(raw, &out)) + require.NotNil(t, out.TPAOperatorBaseline) + require.NotNil(t, out.TPAOperatorEpoch) + assert.Equal(t, uint64(0), *out.TPAOperatorBaseline) + assert.Equal(t, uint64(0), *out.TPAOperatorEpoch) + }) + + t.Run("absence survives", func(t *testing.T) { + raw, err := json.Marshal(SecurityConfig{}) + require.NoError(t, err) + assert.NotContains(t, string(raw), "tpa_operator_baseline") + assert.NotContains(t, string(raw), "tpa_operator_epoch") + assert.NotContains(t, string(raw), "tpa_active_root") + assert.NotContains(t, string(raw), "tpa_operator_signers") + + var out SecurityConfig + require.NoError(t, json.Unmarshal(raw, &out)) + assert.Nil(t, out.TPAOperatorBaseline) + assert.Nil(t, out.TPAOperatorEpoch) + }) + + t.Run("require_signed_bundle always serialised", func(t *testing.T) { + raw, err := json.Marshal(SecurityConfig{}) + require.NoError(t, err) + assert.Contains(t, string(raw), `"require_signed_bundle":false`) + }) +} diff --git a/internal/oauth/configview_tpa_test.go b/internal/oauth/configview_tpa_test.go new file mode 100644 index 000000000..3572e1097 --- /dev/null +++ b/internal/oauth/configview_tpa_test.go @@ -0,0 +1,57 @@ +package oauth + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// TestRedactedConfig_LeavesTPATrustRootUnmasked (Spec 101): the trust-root keys +// are public key material and counters, not secrets. GET /api/v1/config serves +// RedactedConfig, and an operator must be able to read back what they set (and +// the Web UI must be able to round-trip it). A mask here would also be +// rewritten into config on a write-back. Base64 32-byte keys look like +// high-entropy strings, so this pins that the walk does not treat them as +// secrets. +func TestRedactedConfig_LeavesTPATrustRootUnmasked(t *testing.T) { + signer := "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=" + zero, seven := uint64(0), uint64(7) + cfg := config.DefaultConfig() + cfg.Security = &config.SecurityConfig{ + RequireSignedBundle: true, + TPAActiveRoot: config.TPARootOperator, + TPAOperatorSigners: []string{signer}, + TPAOperatorBaseline: &zero, + TPAOperatorEpoch: &seven, + } + + masked := RedactedConfig(cfg) + require.NotNil(t, masked) + require.NotNil(t, masked.Security) + + assert.True(t, masked.Security.RequireSignedBundle) + assert.Equal(t, config.TPARootOperator, masked.Security.TPAActiveRoot) + assert.Equal(t, []string{signer}, masked.Security.TPAOperatorSigners) + require.NotNil(t, masked.Security.TPAOperatorBaseline) + assert.Equal(t, uint64(0), *masked.Security.TPAOperatorBaseline) + require.NotNil(t, masked.Security.TPAOperatorEpoch) + assert.Equal(t, uint64(7), *masked.Security.TPAOperatorEpoch) + + // The wire form is what the REST door serves. + raw, err := json.Marshal(masked.Security) + require.NoError(t, err) + var wire map[string]any + require.NoError(t, json.Unmarshal(raw, &wire)) + assert.Equal(t, []any{signer}, wire["tpa_operator_signers"]) + assert.Equal(t, "operator", wire["tpa_active_root"]) + assert.EqualValues(t, 0, wire["tpa_operator_baseline"]) + assert.EqualValues(t, 7, wire["tpa_operator_epoch"]) + assert.Equal(t, true, wire["require_signed_bundle"]) + + // The source config is not mutated by the redaction walk. + assert.Equal(t, []string{signer}, cfg.Security.TPAOperatorSigners) +} diff --git a/internal/oauth/redactview.go b/internal/oauth/redactview.go index 495c28916..0fe05320c 100644 --- a/internal/oauth/redactview.go +++ b/internal/oauth/redactview.go @@ -766,6 +766,9 @@ func (r Redaction) Value(key string, v interface{}) interface{} { if strings.EqualFold(key, "command") { return r.CapString(r.CommandString(typed)) } + if isPublicKeyMaterialField(key) { + return r.CapString(typed) + } return r.CapString(r.Leaf(key, typed)) default: // Bools, json.Number and nil carry no secrets and stay verbatim. @@ -773,6 +776,18 @@ func (r Redaction) Value(key string, v interface{}) interface{} { } } +// isPublicKeyMaterialField reports whether a leaf's enclosing field holds PUBLIC +// key material that is deliberately readable. Spec 101's +// security.tpa_operator_signers lists operator Ed25519 public keys as base64; +// the value-shaped detector reads a 44-character base64 string as a +// high-entropy secret and would mask it, so an operator could not read back +// what they configured and a UI round trip would write the mask into the trust +// root. A public key is not a credential. Name-matched, not shape-matched, so +// the exemption cannot spread to an arbitrary base64 value. +func isPublicKeyMaterialField(key string) bool { + return strings.EqualFold(key, "tpa_operator_signers") +} + // argvValue masks a command-line argument vector reached through the generic // walk, using the same Argv rules the typed doors use. // diff --git a/internal/runtime/config_hotreload.go b/internal/runtime/config_hotreload.go index a2ef8bcbe..cafd56acc 100644 --- a/internal/runtime/config_hotreload.go +++ b/internal/runtime/config_hotreload.go @@ -447,8 +447,11 @@ func DetectConfigChanges(oldCfg, newCfg *config.Config) *ConfigApplyResult { // that re-apply — otherwise toggling deep scan via config edit / API apply // only takes effect on restart. Deep compare covers deep_scan.{enabled, // fetch_package_source,disable_no_new_privileges,scanners} plus the deprecated - // top-level scanner_* keys. - if !reflect.DeepEqual(oldCfg.Security, newCfg.Security) { + // top-level scanner_* keys. Compared via jsonEqual (the on-disk form), not + // reflect.DeepEqual: Spec 101's tpa_operator_signers is omitempty, so a + // nil-vs-empty slice round trip is the same config and must not read as a + // spurious "security" change. + if !jsonEqual(oldCfg.Security, newCfg.Security) { result.ChangedFields = append(result.ChangedFields, "security") } diff --git a/internal/runtime/config_hotreload_test.go b/internal/runtime/config_hotreload_test.go index 62848881a..94eac42ac 100644 --- a/internal/runtime/config_hotreload_test.go +++ b/internal/runtime/config_hotreload_test.go @@ -147,6 +147,57 @@ func TestDetectConfigChanges_DeepScanSecurity(t *testing.T) { }) } +// TestDetectConfigChanges_TPATrustRoot (Spec 101): the trust-root keys live in +// the security block, so an edit to them must be reported (and applied), while a +// []-vs-nil round trip of tpa_operator_signers (omitempty drops an empty slice, +// so the on-disk form is nil) must NOT count as a change. +func TestDetectConfigChanges_TPATrustRoot(t *testing.T) { + u := func(v uint64) *uint64 { return &v } + mk := func(mut func(*config.SecurityConfig)) *config.Config { + sc := &config.SecurityConfig{} + if mut != nil { + mut(sc) + } + return &config.Config{Listen: "127.0.0.1:8080", DataDir: "/d", TLS: &config.TLSConfig{}, Security: sc} + } + + t.Run("nil vs empty signers is not a change", func(t *testing.T) { + result := DetectConfigChanges( + mk(func(sc *config.SecurityConfig) { sc.TPAOperatorSigners = nil }), + mk(func(sc *config.SecurityConfig) { sc.TPAOperatorSigners = []string{} }), + ) + require.True(t, result.Success) + assert.NotContains(t, result.ChangedFields, "security") + }) + + t.Run("changed signer detected", func(t *testing.T) { + result := DetectConfigChanges( + mk(func(sc *config.SecurityConfig) { sc.TPAOperatorSigners = []string{"AAAA"} }), + mk(func(sc *config.SecurityConfig) { sc.TPAOperatorSigners = []string{"BBBB"} }), + ) + require.True(t, result.Success) + assert.Contains(t, result.ChangedFields, "security") + }) + + t.Run("changed baseline detected", func(t *testing.T) { + result := DetectConfigChanges( + mk(func(sc *config.SecurityConfig) { sc.TPAOperatorBaseline = u(3) }), + mk(func(sc *config.SecurityConfig) { sc.TPAOperatorBaseline = u(4) }), + ) + require.True(t, result.Success) + assert.Contains(t, result.ChangedFields, "security") + }) + + t.Run("absent vs explicit zero baseline detected", func(t *testing.T) { + result := DetectConfigChanges( + mk(nil), + mk(func(sc *config.SecurityConfig) { sc.TPAOperatorBaseline = u(0) }), + ) + require.True(t, result.Success) + assert.Contains(t, result.ChangedFields, "security") + }) +} + func TestDetectConfigChanges(t *testing.T) { baseConfig := &config.Config{ Listen: "127.0.0.1:8080", From 741f7ecba77fc66f4766fc9df228b39891f4fb2a Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 13:30:36 +0300 Subject: [PATCH 09/35] feat(security): Spec 101 US1 pure candidate pipeline and anti-downgrade state logic Adds the deterministic layer of the signed TPA bundle loader: publisher trust anchors and trust-set construction, the config-derived bundle policy with its idempotence fingerprint, the manifest grammar (sequence string, trust epoch, key id, 2000-rule ceiling, compile counter seam), evaluateCandidate with the FR-008 stage order, and the pure state functions (normalize, downgrade check, activation, rollback, coverage). The runtime that drives them lands next. Related #938 --- internal/security/scanner/tpa_bundle.go | 113 ++- .../security/scanner/tpa_bundle_policy.go | 143 +++ .../security/scanner/tpa_bundle_reasons.go | 5 + .../scanner/tpa_bundle_state_logic.go | 381 ++++++++ .../security/scanner/tpa_bundle_state_test.go | 907 ++++++++++++++++++ internal/security/scanner/tpa_candidate.go | 301 ++++++ .../security/scanner/tpa_candidate_test.go | 560 +++++++++++ internal/security/scanner/tpa_helpers_test.go | 313 ++++++ internal/security/scanner/tpa_policy_test.go | 194 ++++ .../security/scanner/tpa_trust_anchors.go | 84 ++ 10 files changed, 3000 insertions(+), 1 deletion(-) create mode 100644 internal/security/scanner/tpa_bundle_policy.go create mode 100644 internal/security/scanner/tpa_bundle_state_logic.go create mode 100644 internal/security/scanner/tpa_bundle_state_test.go create mode 100644 internal/security/scanner/tpa_candidate.go create mode 100644 internal/security/scanner/tpa_candidate_test.go create mode 100644 internal/security/scanner/tpa_helpers_test.go create mode 100644 internal/security/scanner/tpa_policy_test.go create mode 100644 internal/security/scanner/tpa_trust_anchors.go diff --git a/internal/security/scanner/tpa_bundle.go b/internal/security/scanner/tpa_bundle.go index 319e9cec0..181936123 100644 --- a/internal/security/scanner/tpa_bundle.go +++ b/internal/security/scanner/tpa_bundle.go @@ -3,8 +3,10 @@ package scanner import ( _ "embed" "encoding/json" + "errors" "fmt" "regexp" + "strconv" "strings" "go.uber.org/zap" @@ -50,6 +52,91 @@ type rawBundle struct { GeneratedAt string `json:"generated_at"` Rules []rawRule `json:"rules"` Skipped []rawSkip `json:"skipped"` + + // --- Spec 101 manifest fields. They are authenticated only when the + // bundle's detached signature verified; an unsigned candidate's values are + // never trusted for any decision (FR-008, FR-009). --- + + // Sequence is kept raw so a bare JSON number is detectable and refused: + // the published grammar is a decimal STRING (parseSequence). + Sequence json.RawMessage `json:"sequence"` + // KeyID is the manifest's claim about its signing key; it must equal the + // sidecar key id and the id of the key that verified (bundlesig row 13). + KeyID string `json:"key_id"` + // TrustEpoch is kept raw for the same reason as Sequence (parseTrustEpoch). + TrustEpoch json.RawMessage `json:"trust_epoch"` +} + +// bundleCompileHook, when non-nil, is called once per regexp.Compile the +// loader performs. It is a test seam: a compile counter of zero proves that a +// tampered candidate was refused before any pattern was compiled (FR-006). +var bundleCompileHook func() + +// bundleCeilingError reports that a bundle exceeds MaxBundleRules. It is +// raised before anything is compiled and mapped to RejectCeilingExceeded. +type bundleCeilingError struct{ Rules int } + +func (e *bundleCeilingError) Error() string { + return fmt.Sprintf("rules: %d > %d", e.Rules, MaxBundleRules) +} + +// checkRuleCeiling enforces FR-011's rules[] ceiling over ALL entries, +// runnable or not. Exactly MaxBundleRules loads; one more is refused whole. +func checkRuleCeiling(b *rawBundle) error { + if len(b.Rules) > MaxBundleRules { + return &bundleCeilingError{Rules: len(b.Rules)} + } + return nil +} + +// sequenceGrammar is the manifest `sequence` grammar: a decimal string without +// sign, leading zero, exponent or whitespace, at most 20 digits. +var sequenceGrammar = regexp.MustCompile(`^[1-9][0-9]{0,19}$`) + +// parseSequence decodes the manifest `sequence`. It must be a JSON STRING +// matching ^[1-9][0-9]{0,19}$ whose value fits in a uint64. A bare number is +// refused on purpose: a JSON number is a float64 to most parsers and loses +// precision above 2^53, so two implementations could disagree about a +// watermark. It returns the value and its canonical decimal string. +func parseSequence(raw json.RawMessage) (uint64, string, error) { + if len(raw) == 0 || string(raw) == "null" { + return 0, "", errors.New("sequence is missing") + } + if raw[0] != '"' { + return 0, "", errors.New(`sequence must be a JSON string (a decimal like "5"), not a bare number`) + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return 0, "", fmt.Errorf("sequence is not a JSON string: %w", err) + } + if !sequenceGrammar.MatchString(s) { + return 0, "", errors.New("sequence must match ^[1-9][0-9]{0,19}$") + } + v, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return 0, "", errors.New("sequence exceeds 18446744073709551615") + } + return v, s, nil +} + +// parseTrustEpoch decodes the manifest `trust_epoch`: a JSON integer >= 0 with +// no sign, fraction or exponent. +func parseTrustEpoch(raw json.RawMessage) (uint64, error) { + if len(raw) == 0 || string(raw) == "null" { + return 0, errors.New("trust_epoch is missing") + } + // A JSON integer without sign, fraction or exponent is exactly a run of + // ASCII digits (JSON forbids leading zeros except "0" itself). + for _, c := range raw { + if c < '0' || c > '9' { + return 0, errors.New("trust_epoch must be a non-negative JSON integer") + } + } + v, err := strconv.ParseUint(string(raw), 10, 64) + if err != nil { + return 0, errors.New("trust_epoch exceeds 18446744073709551615") + } + return v, nil } // bundleMeta is the operator-facing header of a bundle, extracted without @@ -236,9 +323,30 @@ func defaultBundleCheck() *BundleCheck { // (never failing the load), and // - ignores unknown additive keys (forward-compat). func loadBundleCheck(data []byte) (*BundleCheck, bundleLoadStats, error) { + b, err := parseBundleManifest(data) + if err != nil { + return nil, bundleLoadStats{}, err + } + return compileBundle(b) +} + +// parseBundleManifest is the ONE JSON parse of a candidate's bytes. The +// pipeline calls it on the buffer that was verified, so the bytes parsed are +// the bytes authenticated (FR-006). +func parseBundleManifest(data []byte) (*rawBundle, error) { var b rawBundle if err := json.Unmarshal(data, &b); err != nil { - return nil, bundleLoadStats{}, fmt.Errorf("parse scanner bundle: %w", err) + return nil, fmt.Errorf("parse scanner bundle: %w", err) + } + return &b, nil +} + +// compileBundle version-checks and compiles a parsed manifest, all or nothing. +// The rule-count ceiling is enforced first so an oversized rule list never +// reaches regexp.Compile. +func compileBundle(b *rawBundle) (*BundleCheck, bundleLoadStats, error) { + if err := checkRuleCeiling(b); err != nil { + return nil, bundleLoadStats{}, err } if err := checkBundleVersion(b.BundleVersion); err != nil { @@ -262,6 +370,9 @@ func loadBundleCheck(data []byte) (*BundleCheck, bundleLoadStats, error) { stats.Skipped++ continue } + if bundleCompileHook != nil { + bundleCompileHook() + } re, err := regexp.Compile(r.Pattern) if err != nil { // A single un-compilable pattern rejects the whole candidate so we diff --git a/internal/security/scanner/tpa_bundle_policy.go b/internal/security/scanner/tpa_bundle_policy.go new file mode 100644 index 000000000..3595a187f --- /dev/null +++ b/internal/security/scanner/tpa_bundle_policy.go @@ -0,0 +1,143 @@ +package scanner + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strconv" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/security/bundlesig" +) + +// bundlePolicy is the config-resident half of the bundle trust decision: the +// part an attacker who can write the bundle path or the data directory cannot +// reach (spec 101 FR-004b, FR-007, FR-009b). It is derived once per pipeline +// run from the live SecurityConfig and the binary's own trust anchors. +type bundlePolicy struct { + // Path is the configured bundle path ("" => the embedded default serves). + Path string + // RequireSigned makes the sidecar mandatory for file drops (FR-007). + RequireSigned bool + // ActiveRoot is the declared active signing authority (FR-004b). It is kept + // verbatim: an unrecognised value matches no key's authority, so every + // signed candidate is refused authority_not_active. + ActiveRoot string + // Trust is the union of publisher and valid operator keys. + Trust *bundlesig.TrustSet + + OperatorBaseline uint64 + OperatorBaselineSet bool + OperatorEpoch uint64 + + // PublisherEpoch and PublisherBaseline are the binary's anchors, captured + // when the policy is built so that one run sees one consistent view. + PublisherEpoch uint64 + PublisherBaseline uint64 + + // Fingerprint identifies every field above (and the key set); two runs with + // equal Fingerprint apply identical policy. Part of the idempotence key. + Fingerprint string + // Warnings are dropped-signer notes to log once per distinct policy. + Warnings []string +} + +// policyFromConfig builds the policy from sec, honouring the +// MCPPROXY_TPA_BUNDLE_PATH override through sec.EffectiveTPABundlePath. +func policyFromConfig(sec *config.SecurityConfig) *bundlePolicy { + return policyFor(sec, sec.EffectiveTPABundlePath()) +} + +// policyFor builds the policy for an explicit path (ConfigureBundle passes its +// argument verbatim, with no env override). sec may be nil. +func policyFor(sec *config.SecurityConfig, path string) *bundlePolicy { + trust, keys, warnings := buildTrustKeys(publisherSigners, sec.EffectiveTPAOperatorSigners()) + opBase, opBaseSet := sec.EffectiveTPAOperatorBaseline() + + pubBase := publisherFallbackBaseline + if seq, ok := embeddedSequence(); ok { + pubBase = seq + } + + p := &bundlePolicy{ + Path: path, + RequireSigned: sec.IsRequireSignedBundle(), + ActiveRoot: sec.EffectiveTPAActiveRoot(), + Trust: trust, + OperatorBaseline: opBase, + OperatorBaselineSet: opBaseSet, + OperatorEpoch: sec.EffectiveTPAOperatorEpoch(), + PublisherEpoch: publisherTrustEpoch, + PublisherBaseline: pubBase, + Warnings: warnings, + } + p.Fingerprint = p.fingerprint(keys) + return p +} + +// fingerprint hashes a canonical, length-prefixed encoding of every policy +// field plus the sorted (authority, key id) pairs, so no two distinct policies +// share an encoding and key order is irrelevant. +func (p *bundlePolicy) fingerprint(keys []bundlesig.Key) string { + ids := make([]string, 0, len(keys)) + for _, k := range keys { + ids = append(ids, k.Authority+":"+k.ID) + } + sort.Strings(ids) + + h := sha256.New() + field := func(name, value string) { fmt.Fprintf(h, "%s=%d:%s\n", name, len(value), value) } + field("path", p.Path) + field("require_signed", strconv.FormatBool(p.RequireSigned)) + field("active_root", p.ActiveRoot) + field("operator_baseline_set", strconv.FormatBool(p.OperatorBaselineSet)) + field("operator_baseline", strconv.FormatUint(p.OperatorBaseline, 10)) + field("operator_epoch", strconv.FormatUint(p.OperatorEpoch, 10)) + field("publisher_epoch", strconv.FormatUint(p.PublisherEpoch, 10)) + field("publisher_baseline", strconv.FormatUint(p.PublisherBaseline, 10)) + field("keys", strconv.Itoa(len(ids))) + for _, id := range ids { + field("key", id) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// currentEpoch returns the trusted epoch of auth (FR-005a). An unknown +// authority has epoch 0 and no records. +func (p *bundlePolicy) currentEpoch(auth string) uint64 { + switch auth { + case AuthorityPublisher: + return p.PublisherEpoch + case AuthorityOperator: + return p.OperatorEpoch + } + return 0 +} + +// baseline returns auth's seeded anti-downgrade floor and whether one exists +// (FR-009b). The publisher always has one (the embedded corpus's sequence, else +// the declared fallback); the operator only when configured. +func (p *bundlePolicy) baseline(auth string) (uint64, bool) { + switch auth { + case AuthorityPublisher: + return p.PublisherBaseline, true + case AuthorityOperator: + return p.OperatorBaseline, p.OperatorBaselineSet + } + return 0, false +} + +// embeddedSequence returns the `sequence` of the bundle compiled into this +// binary, and whether it carries a valid one. +func embeddedSequence() (uint64, bool) { + m, err := parseBundleManifest(bundledScannerBundle) + if err != nil { + return 0, false + } + seq, _, err := parseSequence(m.Sequence) + if err != nil { + return 0, false + } + return seq, true +} diff --git a/internal/security/scanner/tpa_bundle_reasons.go b/internal/security/scanner/tpa_bundle_reasons.go index abd569bce..292236aab 100644 --- a/internal/security/scanner/tpa_bundle_reasons.go +++ b/internal/security/scanner/tpa_bundle_reasons.go @@ -57,6 +57,11 @@ const ( // manifest field (sequence grammar, trust_epoch), an unsupported version, a // pattern that does not compile, or no runnable rule. RejectInvalidBundle = "invalid_bundle" + // RejectStateCommitFailed: the anti-downgrade state could not be durably + // committed, so the candidate was NOT activated. Refusing to activate is the + // safe outcome: activating without a durable watermark would reopen the + // replay window FR-011a exists to close. Last-known-good keeps serving. + RejectStateCommitFailed = "state_commit_failed" ) // Degraded-coverage reasons (FR-007, FR-009a, FR-009b; diff --git a/internal/security/scanner/tpa_bundle_state_logic.go b/internal/security/scanner/tpa_bundle_state_logic.go new file mode 100644 index 000000000..df58b65fa --- /dev/null +++ b/internal/security/scanner/tpa_bundle_state_logic.go @@ -0,0 +1,381 @@ +package scanner + +import ( + "fmt" + "strconv" + "time" +) + +// Pure anti-downgrade state functions (spec 101 FR-009, FR-009a, FR-010). They +// never mutate their inputs and never perform I/O, so the runtime can compute a +// candidate state, commit it, and only then publish. + +// bundleAuthorities lists the authorities every normalized state carries. +var bundleAuthorities = [...]string{AuthorityPublisher, AuthorityOperator} + +// cloneState deep-copies st (nil stays nil). +func cloneState(st *TPABundleState) *TPABundleState { + if st == nil { + return nil + } + out := *st + if st.Active != nil { + active := *st.Active + out.Active = &active + } + if st.Authorities != nil { + out.Authorities = make(map[string]*TPAAuthorityState, len(st.Authorities)) + for name, a := range st.Authorities { + out.Authorities[name] = cloneAuthority(a) + } + } + return &out +} + +func cloneAuthority(a *TPAAuthorityState) *TPAAuthorityState { + if a == nil { + return nil + } + out := *a + if a.Pin != nil { + pin := *a.Pin + out.Pin = &pin + } + if a.DenyList != nil { + out.DenyList = append([]TPABundleDenyEntry(nil), a.DenyList...) + } + return &out +} + +// normalizeState returns a clone of st scoped to policy: both authority records +// exist, are scoped to the authority's current trust epoch, and are never below +// the authority's baseline. The second result is the log lines to emit. +// +// Everything that anchors outside the data directory (the publisher baseline, +// the operator baseline, the epochs) is re-applied here on EVERY run, so a +// substituted state file can raise the floor but never lower it (SC-012). +func normalizeState(st *TPABundleState, policy *bundlePolicy) (*TPABundleState, []string) { + out := cloneState(st) + if out == nil { + out = &TPABundleState{} + } + if out.SchemaVersion == 0 { + out.SchemaVersion = TPABundleStateSchemaVersion + } + if out.Authorities == nil { + out.Authorities = make(map[string]*TPAAuthorityState, len(bundleAuthorities)) + } + + var logs []string + for _, auth := range bundleAuthorities { + cur := policy.currentEpoch(auth) + base, _ := policy.baseline(auth) // an unbaselined operator seeds 0 + rec := out.Authorities[auth] + + switch { + case rec == nil: + out.Authorities[auth] = &TPAAuthorityState{Epoch: cur, Watermark: base} + case rec.Epoch != cur: + retired := len(rec.DenyList) + if rec.Pin != nil { + retired++ + } + logs = append(logs, fmt.Sprintf( + "trust epoch for %s changed %d→%d: watermark re-baselined to %d, %d pin/deny entries retired", + auth, rec.Epoch, cur, base, retired)) + out.Authorities[auth] = &TPAAuthorityState{Epoch: cur, Watermark: base} + case rec.Watermark < base: + // The baseline only ever raises the floor. The floor is now the + // baseline, not a bundle, so the recorded setter no longer applies. + rec.Watermark = base + rec.WatermarkSetBy = "" + } + } + return out, logs +} + +// pinBinds reports whether a rollback pin governs activation for a. After +// normalizeState a pin can only exist for the authority's current epoch, and +// only the active root's record is ever consulted. +func pinBinds(a *TPAAuthorityState) bool { return a != nil && a.Pin != nil } + +// denied reports whether the exact release (seq, digest) was rolled away from. +func denied(a *TPAAuthorityState, seq uint64, digest string) bool { + if a == nil { + return false + } + for _, d := range a.DenyList { + if d.Sequence == seq && d.FullDigest == digest { + return true + } + } + return false +} + +// downgradeCheck decides whether a signed candidate (seq, digest) of authority +// auth may activate against a. It returns "" when allowed, otherwise the +// human detail of the downgrade rejection. +// +// A candidate is allowed when ANY of: +// - its sequence is above the watermark (a normal advance); +// - it is the very release that set the watermark (a restart or LKG loss); +// - a pin binds and it is the pinned target; +// - this is a rollback and it carries the digest the operator named. +// +// The last two additionally require the sequence to be at or above the +// authority's OWN baseline, never another authority's: that keeps a forged pin +// or a substituted rollback target from landing below the anchors that live +// outside the data directory. Everything else is a downgrade, including an +// equal sequence with a different digest. +func downgradeCheck(a *TPAAuthorityState, auth string, policy *bundlePolicy, seq uint64, digest string, mode evalMode, rollbackDigest string) string { + var watermark uint64 + var setBy string + if a != nil { + watermark, setBy = a.Watermark, a.WatermarkSetBy + } + base, hasBase := policy.baseline(auth) + if hasBase && base > watermark { + // Defence in depth for a state that was not normalized. + watermark, setBy = base, "" + } + + if seq > watermark { + return "" + } + if seq == watermark && setBy != "" && digest == setBy { + return "" + } + + pinned := pinBinds(a) && digest == a.Pin.FullDigest + rollingBack := mode == evalRollback && rollbackDigest != "" && digest == rollbackDigest + if pinned || rollingBack { + if hasBase && seq < base { + return fmt.Sprintf("below %s baseline %d", auth, base) + } + return "" + } + + detail := fmt.Sprintf("sequence %d, watermark %d", seq, watermark) + if setBy != "" { + detail += ", set by " + shortDigest(setBy) + } + return detail +} + +// ensureAuthority returns out's record for auth, creating one scoped to epoch +// when the state was not normalized. +func ensureAuthority(out *TPABundleState, auth string, epoch uint64) *TPAAuthorityState { + if out.Authorities == nil { + out.Authorities = make(map[string]*TPAAuthorityState, len(bundleAuthorities)) + } + a := out.Authorities[auth] + if a == nil { + a = &TPAAuthorityState{Epoch: epoch} + out.Authorities[auth] = a + } + return a +} + +// sequenceString is the published decimal form; 0 (unsigned, or an embedded +// bundle without a sequence) is "". +func sequenceString(seq uint64) string { + if seq == 0 { + return "" + } + return strconv.FormatUint(seq, 10) +} + +// recordSignedActivation applies what a signature-verified activation does to +// out: arm the ratchet, note that this authority has activated an external +// bundle, and raise the watermark when the sequence is above it. +func recordSignedActivation(out *TPABundleState, auth string, epoch, seq uint64, digest string) { + out.SignatureRatchet = true + a := ensureAuthority(out, auth, epoch) + a.ExternalEverActivated = true + if seq > a.Watermark { + a.Watermark = seq + a.WatermarkSetBy = digest + // A normal advance supersedes any pin. + a.Pin = nil + } +} + +// applyActivation returns the state after activating acc, and the history row +// for that activation. An unsigned activation changes neither the watermark nor +// the ratchet: its manifest is attacker-chosen. +func applyActivation(st *TPABundleState, acc *acceptedBundle, cand Candidate, now time.Time) (*TPABundleState, *TPABundleActivation) { + out := cloneState(st) + if out == nil { + out = &TPABundleState{SchemaVersion: TPABundleStateSchemaVersion} + } + if acc.Verified { + recordSignedActivation(out, acc.Authority, acc.TrustEpoch, acc.Sequence, acc.FullDigest) + } + out.Active = &TPABundleActiveRecord{ + Authority: acc.Authority, + TrustEpoch: acc.TrustEpoch, + Sequence: acc.Sequence, + FullDigest: acc.FullDigest, + SidecarDigest: acc.SidecarDigest, + SignatureVerified: acc.Verified, + Source: cand.Source, + Path: cand.Path, + GeneratedAt: acc.Meta.GeneratedAt, + ActivatedAt: now, + } + return out, &TPABundleActivation{ + Authority: acc.Authority, + TrustEpoch: acc.TrustEpoch, + Sequence: sequenceString(acc.Sequence), + FullDigest: acc.FullDigest, + GeneratedAt: acc.Meta.GeneratedAt, + ActivatedAt: now, + Source: cand.Source, + SignatureVerified: acc.Verified, + } +} + +// rollbackTarget describes the release an explicit rollback activates. +type rollbackTarget struct { + // Embedded is true when the target is the binary's embedded default. + Embedded bool + Authority string + TrustEpoch uint64 + Sequence uint64 + FullDigest string + SidecarDigest string + SignatureVerified bool + Source string + Path string + GeneratedAt string +} + +// rollbackTargetFromAccepted describes a file-sourced rollback target. +func rollbackTargetFromAccepted(acc *acceptedBundle, cand Candidate) rollbackTarget { + return rollbackTarget{ + Authority: acc.Authority, + TrustEpoch: acc.TrustEpoch, + Sequence: acc.Sequence, + FullDigest: acc.FullDigest, + SidecarDigest: acc.SidecarDigest, + SignatureVerified: acc.Verified, + Source: cand.Source, + Path: cand.Path, + GeneratedAt: acc.Meta.GeneratedAt, + } +} + +// embeddedRollbackTarget describes the embedded default as a rollback target. +// The embedded default is a publisher artifact that rides on binary integrity, +// so it is never "signature verified". +func embeddedRollbackTarget(policy *bundlePolicy, digest string, seq uint64, generatedAt string) rollbackTarget { + return rollbackTarget{ + Embedded: true, + Authority: AuthorityPublisher, + TrustEpoch: policy.currentEpoch(AuthorityPublisher), + Sequence: seq, + FullDigest: digest, + Source: BundleSourceEmbedded, + GeneratedAt: generatedAt, + } +} + +// applyRollback returns the state after an explicit rollback to target, away +// from rolledAway (the record of the bundle that was serving; nil when the +// embedded default was), and the history row. +// +// The watermark is never lowered. The release rolled away from is deny-listed +// under its own authority (when it was signed and its epoch is still current) +// so the next refresh cannot re-activate it, and the target is pinned under the +// active root so the intermediate releases between the target and the watermark +// stay refused until a genuinely newer release activates. +func applyRollback(st *TPABundleState, policy *bundlePolicy, target rollbackTarget, rolledAway *TPABundleActiveRecord, now time.Time) (*TPABundleState, *TPABundleActivation) { + out := cloneState(st) + if out == nil { + out = &TPABundleState{SchemaVersion: TPABundleStateSchemaVersion} + } + + if rolledAway != nil && rolledAway.SignatureVerified && rolledAway.Authority != "" { + if a := out.Authorities[rolledAway.Authority]; a != nil && a.Epoch == rolledAway.TrustEpoch && + !denied(a, rolledAway.Sequence, rolledAway.FullDigest) { + a.DenyList = append(a.DenyList, TPABundleDenyEntry{ + Sequence: rolledAway.Sequence, + FullDigest: rolledAway.FullDigest, + DeniedAt: now, + }) + } + } + + if !target.Embedded && target.SignatureVerified && target.Authority != "" { + recordSignedActivation(out, target.Authority, target.TrustEpoch, target.Sequence, target.FullDigest) + } + + if root := policy.ActiveRoot; root == AuthorityPublisher || root == AuthorityOperator { + ensureAuthority(out, root, policy.currentEpoch(root)).Pin = &TPABundlePin{ + Sequence: target.Sequence, + FullDigest: target.FullDigest, + Embedded: target.Embedded, + PinnedAt: now, + } + } + + if target.Embedded { + out.Active = nil + } else { + out.Active = &TPABundleActiveRecord{ + Authority: target.Authority, + TrustEpoch: target.TrustEpoch, + Sequence: target.Sequence, + FullDigest: target.FullDigest, + SidecarDigest: target.SidecarDigest, + SignatureVerified: target.SignatureVerified, + Source: target.Source, + Path: target.Path, + GeneratedAt: target.GeneratedAt, + ActivatedAt: now, + Rollback: true, + } + } + return out, &TPABundleActivation{ + Authority: target.Authority, + TrustEpoch: target.TrustEpoch, + Sequence: sequenceString(target.Sequence), + FullDigest: target.FullDigest, + GeneratedAt: target.GeneratedAt, + ActivatedAt: now, + Source: target.Source, + SignatureVerified: target.SignatureVerified, + Rollback: true, + } +} + +// servingFacts is what computeCoverage needs to know about the bundle that is +// serving. +type servingFacts struct { + Embedded bool + Verified bool + Authority string +} + +// computeCoverage decides whether scan-mode auto-approval must be suspended. +// It applies exactly the four degrade conditions of FR-007 / FR-009a / FR-009b, +// in this order, and nothing else: in particular no sequence is ever compared +// across authorities, and a missing PUBLISHER bundle never degrades by itself. +func computeCoverage(serving servingFacts, st *TPABundleState, policy *bundlePolicy, embeddedSeq uint64) (bool, string) { + var pub, op *TPAAuthorityState + if st != nil { + pub, op = st.Authorities[AuthorityPublisher], st.Authorities[AuthorityOperator] + } + + switch { + case !serving.Embedded && !serving.Verified: + return true, DegradedUnsignedExternalActive + case !serving.Embedded && serving.Authority == AuthorityOperator && !policy.OperatorBaselineSet: + return true, DegradedOperatorAuthorityUnbaselined + case serving.Embedded && pub != nil && embeddedSeq < pub.Watermark: + return true, DegradedEmbeddedBelowWatermark + case serving.Embedded && policy.ActiveRoot == AuthorityOperator && op != nil && op.ExternalEverActivated: + return true, DegradedOperatorBundleMissing + } + return false, "" +} diff --git a/internal/security/scanner/tpa_bundle_state_test.go b/internal/security/scanner/tpa_bundle_state_test.go new file mode 100644 index 000000000..35f47eaf1 --- /dev/null +++ b/internal/security/scanner/tpa_bundle_state_test.go @@ -0,0 +1,907 @@ +package scanner + +import ( + "crypto/ed25519" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// opRoot configures the operator authority as the active root with a baseline. +func opRoot(baseline uint64) func(*config.SecurityConfig) { + return func(s *config.SecurityConfig) { + s.TPAActiveRoot = config.TPARootOperator + s.TPAOperatorBaseline = u64p(baseline) + } +} + +// --- normalizeState / cloneState ------------------------------------------------ + +func TestNormalizeState_SeedsBothAuthoritiesFromTheirBaselines(t *testing.T) { + withTrustAnchors(t, []ed25519.PublicKey{keyPublisher.pub}, 2, buildBundle(t, bundleOpts{Seq: 100})) + p := testPolicy(func(s *config.SecurityConfig) { s.TPAOperatorBaseline = u64p(10); s.TPAOperatorEpoch = u64p(3) }) + + st, logs := normalizeState(nil, p) + require.NotNil(t, st) + assert.Equal(t, TPABundleStateSchemaVersion, st.SchemaVersion) + assert.Empty(t, logs, "a fresh install logs nothing") + assert.Equal(t, &TPAAuthorityState{Epoch: 2, Watermark: 100}, st.Authorities[AuthorityPublisher]) + assert.Equal(t, &TPAAuthorityState{Epoch: 3, Watermark: 10}, st.Authorities[AuthorityOperator]) + assert.False(t, st.SignatureRatchet) + assert.Nil(t, st.Active) +} + +func TestNormalizeState_OperatorWithoutBaselineSeedsZero(t *testing.T) { + withTrustAnchors(t, nil, 0, buildBundle(t, bundleOpts{Seq: 100})) + st, _ := normalizeState(nil, testPolicy(nil)) + assert.Equal(t, uint64(0), st.Authorities[AuthorityOperator].Watermark) +} + +func TestNormalizeState_DoesNotMutateItsInput(t *testing.T) { + withTrustAnchors(t, nil, 1, buildBundle(t, bundleOpts{Seq: 100})) + in := &TPABundleState{ + Authorities: map[string]*TPAAuthorityState{ + AuthorityPublisher: {Epoch: 0, Watermark: 7, WatermarkSetBy: "abc", Pin: &TPABundlePin{FullDigest: "p"}, DenyList: []TPABundleDenyEntry{{FullDigest: "d"}}}, + }, + } + before := cloneState(in) + out, _ := normalizeState(in, testPolicy(nil)) + + assert.Equal(t, before, in, "the committed mirror is never mutated") + assert.NotSame(t, in, out) + assert.NotSame(t, in.Authorities[AuthorityPublisher], out.Authorities[AuthorityPublisher]) +} + +func TestNormalizeState_EpochChangeRescopesAndRetiresPinsAndDenyEntries(t *testing.T) { + withTrustAnchors(t, nil, 1, buildBundle(t, bundleOpts{Seq: 100})) + in := &TPABundleState{ + SignatureRatchet: true, + Authorities: map[string]*TPAAuthorityState{ + AuthorityPublisher: { + Epoch: 0, Watermark: 18446744073709551615, WatermarkSetBy: "attacker", ExternalEverActivated: true, + Pin: &TPABundlePin{Sequence: 5, FullDigest: "pin"}, + DenyList: []TPABundleDenyEntry{{Sequence: 9, FullDigest: "d1"}}, + }, + }, + } + out, logs := normalizeState(in, testPolicy(nil)) + + pub := out.Authorities[AuthorityPublisher] + assert.Equal(t, uint64(1), pub.Epoch) + assert.Equal(t, uint64(100), pub.Watermark, "re-baselined from the binary's own corpus, not zero") + assert.Equal(t, "", pub.WatermarkSetBy) + assert.False(t, pub.ExternalEverActivated) + assert.Nil(t, pub.Pin) + assert.Empty(t, pub.DenyList) + assert.True(t, out.SignatureRatchet, "the ratchet is install-wide and survives an epoch bump") + + require.Len(t, logs, 1) + assert.Contains(t, logs[0], "trust epoch for publisher changed 0→1") + assert.Contains(t, logs[0], "re-baselined to 100") + assert.Contains(t, logs[0], "2 pin/deny entries retired") +} + +func TestNormalizeState_BaselineIsReappliedAndOnlyEverRaises(t *testing.T) { + withTrustAnchors(t, nil, 0, buildBundle(t, bundleOpts{Seq: 100})) + p := testPolicy(func(s *config.SecurityConfig) { s.TPAOperatorBaseline = u64p(50) }) + + t.Run("a lowered watermark is raised back and loses its setter", func(t *testing.T) { + in := &TPABundleState{Authorities: map[string]*TPAAuthorityState{ + AuthorityPublisher: {Watermark: 3, WatermarkSetBy: "forged"}, + AuthorityOperator: {Watermark: 0, WatermarkSetBy: "forged"}, + }} + out, _ := normalizeState(in, p) + assert.Equal(t, uint64(100), out.Authorities[AuthorityPublisher].Watermark) + assert.Equal(t, "", out.Authorities[AuthorityPublisher].WatermarkSetBy) + assert.Equal(t, uint64(50), out.Authorities[AuthorityOperator].Watermark) + assert.Equal(t, "", out.Authorities[AuthorityOperator].WatermarkSetBy) + }) + + t.Run("a higher recorded watermark is kept with its setter", func(t *testing.T) { + in := &TPABundleState{Authorities: map[string]*TPAAuthorityState{ + AuthorityPublisher: {Watermark: 150, WatermarkSetBy: "real"}, + }} + out, _ := normalizeState(in, p) + assert.Equal(t, uint64(150), out.Authorities[AuthorityPublisher].Watermark) + assert.Equal(t, "real", out.Authorities[AuthorityPublisher].WatermarkSetBy) + }) + + t.Run("an equal watermark keeps its setter", func(t *testing.T) { + in := &TPABundleState{Authorities: map[string]*TPAAuthorityState{ + AuthorityPublisher: {Watermark: 100, WatermarkSetBy: "release"}, + }} + out, _ := normalizeState(in, p) + assert.Equal(t, "release", out.Authorities[AuthorityPublisher].WatermarkSetBy) + }) +} + +func TestCloneState_IsDeep(t *testing.T) { + assert.Nil(t, cloneState(nil)) + + in := &TPABundleState{ + SignatureRatchet: true, + Active: &TPABundleActiveRecord{FullDigest: "a"}, + Authorities: map[string]*TPAAuthorityState{ + AuthorityPublisher: {Watermark: 5, Pin: &TPABundlePin{FullDigest: "p"}, DenyList: []TPABundleDenyEntry{{FullDigest: "d"}}}, + }, + } + out := cloneState(in) + require.Equal(t, in, out) + + out.Active.FullDigest = "changed" + out.Authorities[AuthorityPublisher].Watermark = 99 + out.Authorities[AuthorityPublisher].Pin.FullDigest = "changed" + out.Authorities[AuthorityPublisher].DenyList[0].FullDigest = "changed" + out.Authorities[AuthorityPublisher].DenyList = append(out.Authorities[AuthorityPublisher].DenyList, TPABundleDenyEntry{}) + + assert.Equal(t, "a", in.Active.FullDigest) + assert.Equal(t, uint64(5), in.Authorities[AuthorityPublisher].Watermark) + assert.Equal(t, "p", in.Authorities[AuthorityPublisher].Pin.FullDigest) + assert.Equal(t, "d", in.Authorities[AuthorityPublisher].DenyList[0].FullDigest) + assert.Len(t, in.Authorities[AuthorityPublisher].DenyList, 1) +} + +// --- downgradeCheck / denied / pinBinds ----------------------------------------- + +func TestDowngradeCheck_AllowRules(t *testing.T) { + withTrustAnchors(t, nil, 0, buildBundle(t, bundleOpts{Seq: 10})) + p := testPolicy(opRoot(5)) + + a := &TPAAuthorityState{Watermark: 20, WatermarkSetBy: "setter"} + pinned := &TPAAuthorityState{Watermark: 20, WatermarkSetBy: "setter", Pin: &TPABundlePin{Sequence: 15, FullDigest: "pin"}} + + cases := []struct { + name string + a *TPAAuthorityState + auth string + seq uint64 + digest string + mode evalMode + rb string + allow bool + detail string + }{ + {"above the watermark", a, AuthorityPublisher, 21, "x", evalNormal, "", true, ""}, + {"max sequence", a, AuthorityPublisher, 18446744073709551615, "x", evalNormal, "", true, ""}, + {"the release that set the watermark (restart / LKG loss)", a, AuthorityPublisher, 20, "setter", evalNormal, "", true, ""}, + {"same release in lkg mode", a, AuthorityPublisher, 20, "setter", evalLKG, "", true, ""}, + {"equal sequence, different digest", a, AuthorityPublisher, 20, "other", evalNormal, "", false, "sequence 20, watermark 20, set by setter"}, + {"below the watermark", a, AuthorityPublisher, 19, "x", evalNormal, "", false, "sequence 19, watermark 20"}, + {"a seeded-baseline watermark has no setter to match", &TPAAuthorityState{Watermark: 10}, AuthorityPublisher, 10, "x", evalNormal, "", false, "sequence 10, watermark 10"}, + {"pinned target below the watermark", pinned, AuthorityPublisher, 15, "pin", evalNormal, "", true, ""}, + {"pinned target in lkg mode", pinned, AuthorityPublisher, 15, "pin", evalLKG, "", true, ""}, + {"an intermediate release is refused while a pin binds", pinned, AuthorityPublisher, 17, "mid", evalNormal, "", false, "sequence 17, watermark 20"}, + {"pin matches but the sequence sits below the baseline", &TPAAuthorityState{Watermark: 20, Pin: &TPABundlePin{FullDigest: "pin"}}, AuthorityPublisher, 9, "pin", evalNormal, "", false, "below publisher baseline 10"}, + {"rollback to the named digest", a, AuthorityPublisher, 12, "target", evalRollback, "target", true, ""}, + {"rollback to a digest other than the named one", a, AuthorityPublisher, 12, "target", evalRollback, "someone-else", false, "sequence 12, watermark 20"}, + {"rollback at exactly the baseline", a, AuthorityPublisher, 10, "target", evalRollback, "target", true, ""}, + {"rollback below the publisher baseline", a, AuthorityPublisher, 9, "target", evalRollback, "target", false, "below publisher baseline 10"}, + {"operator rollback measured against the OPERATOR baseline", a, AuthorityOperator, 6, "target", evalRollback, "target", true, ""}, + {"operator rollback below the operator baseline", a, AuthorityOperator, 4, "target", evalRollback, "target", false, "below operator baseline 5"}, + {"normal mode ignores the rollback digest", a, AuthorityPublisher, 12, "target", evalNormal, "target", false, "sequence 12, watermark 20"}, + {"nil authority record is an empty one (above the baseline)", nil, AuthorityPublisher, 11, "x", evalNormal, "", true, ""}, + {"nil authority record still enforces the baseline", nil, AuthorityPublisher, 10, "x", evalNormal, "", false, "sequence 10, watermark 10"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := downgradeCheck(tc.a, tc.auth, p, tc.seq, tc.digest, tc.mode, tc.rb) + if tc.allow { + assert.Equal(t, "", got) + return + } + require.NotEmpty(t, got) + assert.Contains(t, got, tc.detail) + }) + } + + t.Run("an operator authority with no baseline has no floor to enforce", func(t *testing.T) { + np := testPolicy(nil) + assert.Equal(t, "", downgradeCheck(a, AuthorityOperator, np, 1, "target", evalRollback, "target")) + }) +} + +func TestDeniedAndPinBinds(t *testing.T) { + a := &TPAAuthorityState{DenyList: []TPABundleDenyEntry{{Sequence: 7, FullDigest: "d7"}}} + assert.True(t, denied(a, 7, "d7")) + assert.False(t, denied(a, 7, "other"), "a digest the operator did not reject") + assert.False(t, denied(a, 8, "d7"), "the key is (sequence, digest)") + assert.False(t, denied(&TPAAuthorityState{}, 7, "d7")) + assert.False(t, denied(nil, 7, "d7")) + + assert.False(t, pinBinds(nil)) + assert.False(t, pinBinds(&TPAAuthorityState{})) + assert.True(t, pinBinds(&TPAAuthorityState{Pin: &TPABundlePin{FullDigest: "p"}})) +} + +// --- US1 Independent Test (a)-(i), at the pure layer ----------------------------- + +func TestUS1IndependentTest(t *testing.T) { + // Embedded sequence 100 is the publisher baseline; operator baseline 10. + root := func(s *config.SecurityConfig) { s.TPAOperatorBaseline = u64p(10) } + + t.Run("activation records the sequence, ratchet and watermark", func(t *testing.T) { + f := newPipelineFixture(t, 100, root) + c := signedCandidate(t, keyPublisher, bundleOpts{Seq: 101}) + acc := f.activate(c) + + assert.True(t, acc.Verified) + assert.Equal(t, uint64(101), acc.Sequence) + pub := f.auth(AuthorityPublisher) + assert.Equal(t, uint64(101), pub.Watermark) + assert.Equal(t, digestOf(c.Bundle), pub.WatermarkSetBy) + assert.True(t, pub.ExternalEverActivated) + assert.True(t, f.st.SignatureRatchet) + require.NotNil(t, f.st.Active) + assert.Equal(t, digestOf(c.Bundle), f.st.Active.FullDigest) + assert.Equal(t, digestOf(c.Sidecar), f.st.Active.SidecarDigest) + assert.True(t, f.st.Active.SignatureVerified) + }) + + activeFixture := func(t *testing.T) (*pipelineFixture, Candidate) { + f := newPipelineFixture(t, 100, root) + c := signedCandidate(t, keyPublisher, bundleOpts{Seq: 101, Tag: "active"}) + f.activate(c) + return f, c + } + + t.Run("(a) one flipped byte", func(t *testing.T) { + f, _ := activeFixture(t) + c := signedCandidate(t, keyPublisher, bundleOpts{Seq: 102, Tag: "bbb"}) + c.Bundle = flipByte(t, c.Bundle, "bundle-marker") + f.refuse(c, RejectTamper) + }) + + t.Run("(b) a signature from a non-trusted key", func(t *testing.T) { + f, _ := activeFixture(t) + f.refuse(signedCandidate(t, keyUntrusted, bundleOpts{Seq: 102}), RejectKeyNotTrusted) + }) + + t.Run("(c) missing sidecar with require_signed_bundle", func(t *testing.T) { + f, _ := activeFixture(t) + f.repolicy(func(s *config.SecurityConfig) { root(s); s.RequireSignedBundle = true }) + f.refuse(unsignedCandidate(t, bundleOpts{Seq: 102}), RejectSignatureRequired) + }) + + t.Run("(d) a lower sequence", func(t *testing.T) { + f, _ := activeFixture(t) + rej := f.refuse(signedCandidate(t, keyPublisher, bundleOpts{Seq: 100, Tag: "older"}), RejectDowngrade) + assert.Contains(t, rej.Detail, "sequence 100, watermark 101") + }) + + t.Run("(e) a failing activation self-check", func(t *testing.T) { + f, _ := activeFixture(t) + withSelfCheck(t, func(*BundleCheck) error { return assert.AnError }) + f.refuse(signedCandidate(t, keyPublisher, bundleOpts{Seq: 102}), RejectGateFailure) + }) + + t.Run("(f) unsigned after a signed one (ratchet)", func(t *testing.T) { + f, _ := activeFixture(t) + f.refuse(unsignedCandidate(t, bundleOpts{Seq: 102}), RejectUnsignedRatchet) + }) + + t.Run("(g) an unsigned bundle claiming a huge sequence leaves the watermark alone", func(t *testing.T) { + f := newPipelineFixture(t, 100, root) + huge := unsignedCandidate(t, bundleOpts{SeqRaw: `"18446744073709551615"`, Epoch: 0}) + f.activate(huge) + + assert.Equal(t, uint64(100), f.auth(AuthorityPublisher).Watermark, "an unsigned candidate never advances the watermark") + assert.False(t, f.st.SignatureRatchet, "an unsigned activation never arms the ratchet") + require.NotNil(t, f.st.Active) + assert.False(t, f.st.Active.SignatureVerified) + assert.Equal(t, "", f.st.Active.Authority) + + // ...so a later legitimate signed release still activates. + acc := f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 101})) + assert.Equal(t, uint64(101), acc.Sequence) + assert.Equal(t, uint64(101), f.auth(AuthorityPublisher).Watermark) + }) + + t.Run("(h) fresh install: an old genuinely-signed bundle is refused by the seeded baseline", func(t *testing.T) { + f := newPipelineFixture(t, 100, root) + require.Nil(t, f.st.Active) + rej := f.refuse(signedCandidate(t, keyPublisher, bundleOpts{Seq: 50}), RejectDowngrade) + assert.Contains(t, rej.Detail, "watermark 100") + f.refuse(signedCandidate(t, keyPublisher, bundleOpts{Seq: 100, Tag: "same-seq-other-bytes"}), RejectDowngrade) + }) + + t.Run("(i) operator-signed while the publisher authority is active", func(t *testing.T) { + f, c := activeFixture(t) + rej := f.refuse(signedCandidate(t, keyOperator, bundleOpts{Seq: 11}), RejectAuthorityNotActive) + assert.Contains(t, rej.Detail, "verified by operator key "+keyOperator.id) + assert.Contains(t, rej.Detail, "active root is publisher") + assert.Equal(t, digestOf(c.Bundle), f.st.Active.FullDigest, "the publisher bundle keeps serving") + }) +} + +// SC-002: equal sequence + same digest is an idempotent re-load; a different +// digest at the same sequence is a downgrade. +func TestEqualSequenceRules(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + c := signedCandidate(t, keyPublisher, bundleOpts{Seq: 150, Tag: "a"}) + f.activate(c) + + acc, rej := f.eval(c) + require.Nil(t, rej, "re-loading the release that set the watermark (restart, LKG loss) is allowed") + assert.Equal(t, uint64(150), acc.Sequence) + + f.refuse(signedCandidate(t, keyPublisher, bundleOpts{Seq: 150, Tag: "b"}), RejectDowngrade) + + // Re-activating it changes nothing. + before := cloneState(f.st) + f.st, _ = applyActivation(f.st, acc, c, fixedNow.Add(time.Hour)) + f.renormalize() + assert.Equal(t, before.Authorities, f.st.Authorities) +} + +// --- SC-002a ---------------------------------------------------------------------- + +func TestSC002a_MaxSequenceThenEpochBumpRecovers(t *testing.T) { + survivor := newTestKey(0x44) + f := newPipelineFixture(t, 100, nil) + + // (a) A compromised publisher key signs the maximum sequence. + compromised := signedCandidate(t, keyPublisher, bundleOpts{SeqRaw: `"18446744073709551615"`, Tag: "evil"}) + f.activate(compromised) + require.Equal(t, uint64(18446744073709551615), f.auth(AuthorityPublisher).Watermark) + + // Nothing under the same epoch can ever beat that watermark. + f.refuse(signedCandidate(t, keyPublisher, bundleOpts{Seq: 200}), RejectDowngrade) + + // The recovery release revokes the key and bumps the publisher epoch. + withTrustAnchors(t, []ed25519.PublicKey{survivor.pub}, 1, nil) + f.repolicy(nil) + pub := f.auth(AuthorityPublisher) + assert.Equal(t, uint64(1), pub.Epoch) + assert.Equal(t, uint64(100), pub.Watermark, "re-baselined from the binary's corpus") + + recovery := signedCandidate(t, survivor, bundleOpts{Seq: 101, Epoch: 1}) + acc, rej := f.eval(recovery) + require.Nil(t, rej, "the recovery release supersedes the retained watermark") + assert.Equal(t, uint64(1), acc.TrustEpoch) + + // (a2) The old bundle is refused as a stale-epoch replay even though it is + // validly signed by a still-trusted key and carries the maximum sequence. + oldButTrusted := signedCandidate(t, survivor, bundleOpts{SeqRaw: `"18446744073709551615"`, Epoch: 0, Tag: "replay"}) + rej2 := f.refuse(oldButTrusted, RejectStaleEpochReplay) + assert.Contains(t, rej2.Detail, "epoch 0") + // The compromised key itself is no longer trusted at all. + f.refuse(compromised, RejectKeyNotTrusted) +} + +func TestSC002a_AuthoritiesAreIsolated(t *testing.T) { + // (b) operator watermark 900 vs publisher 100: neither blocks the other. + f := newPipelineFixture(t, 100, opRoot(10)) + f.activate(signedCandidate(t, keyOperator, bundleOpts{Seq: 900})) + assert.Equal(t, uint64(900), f.auth(AuthorityOperator).Watermark) + assert.Equal(t, uint64(100), f.auth(AuthorityPublisher).Watermark, "an operator activation never touches the publisher counter") + + f.refuse(signedCandidate(t, keyOperator, bundleOpts{Seq: 100, Tag: "low"}), RejectDowngrade) + + // Declaring the publisher root: seq 101 activates although 101 < 900. + f.repolicy(func(s *config.SecurityConfig) { s.TPAOperatorBaseline = u64p(10) }) + f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 101})) + assert.Equal(t, uint64(101), f.auth(AuthorityPublisher).Watermark) + assert.Equal(t, uint64(900), f.auth(AuthorityOperator).Watermark, "a publisher activation never touches the operator counter") + + // And back: the operator counter is exactly where it was. + f.repolicy(opRoot(10)) + f.activate(signedCandidate(t, keyOperator, bundleOpts{Seq: 901})) + f.refuse(signedCandidate(t, keyOperator, bundleOpts{Seq: 500, Tag: "mid"}), RejectDowngrade) +} + +func TestSC002a_AuthorityNotActiveBothDirections(t *testing.T) { + t.Run("operator key, publisher root", func(t *testing.T) { + f := newPipelineFixture(t, 100, opRoot(10)) + f.repolicy(func(s *config.SecurityConfig) { s.TPAOperatorBaseline = u64p(10) }) + rej := f.refuse(signedCandidate(t, keyOperator, bundleOpts{Seq: 11}), RejectAuthorityNotActive) + assert.Contains(t, rej.Detail, "active root is publisher") + }) + t.Run("publisher key, operator root", func(t *testing.T) { + f := newPipelineFixture(t, 100, opRoot(10)) + rej := f.refuse(signedCandidate(t, keyPublisher, bundleOpts{Seq: 101}), RejectAuthorityNotActive) + assert.Contains(t, rej.Detail, "verified by publisher key "+keyPublisher.id) + assert.Contains(t, rej.Detail, "active root is operator") + }) + t.Run("an unrecognised root matches no key", func(t *testing.T) { + f := newPipelineFixture(t, 100, func(s *config.SecurityConfig) { s.TPAActiveRoot = "bogus" }) + f.refuse(signedCandidate(t, keyPublisher, bundleOpts{Seq: 101}), RejectAuthorityNotActive) + f.refuse(signedCandidate(t, keyOperator, bundleOpts{Seq: 11}), RejectAuthorityNotActive) + }) + t.Run("a non-active-root key is reported as such, not as untrusted", func(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + _, rej := f.eval(signedCandidate(t, keyOperator, bundleOpts{Seq: 11})) + require.NotNil(t, rej) + assert.NotEqual(t, RejectKeyNotTrusted, rej.Reason) + }) +} + +func TestSC002a_OperatorBaseline(t *testing.T) { + t.Run("after a data-directory wipe the config baseline still refuses an old operator bundle", func(t *testing.T) { + f := newPipelineFixture(t, 100, opRoot(10)) + f.st = nil // wiped + f.renormalize() + + rej := f.refuse(signedCandidate(t, keyOperator, bundleOpts{Seq: 5}), RejectDowngrade) + assert.Contains(t, rej.Detail, "watermark 10") + f.refuse(signedCandidate(t, keyOperator, bundleOpts{Seq: 10}), RejectDowngrade) // must be ABOVE the baseline + acc := f.activate(signedCandidate(t, keyOperator, bundleOpts{Seq: 11})) + assert.Equal(t, AuthorityOperator, acc.Authority) + }) + + t.Run("an operator with no baseline accepts any sequence (and runs degraded)", func(t *testing.T) { + f := newPipelineFixture(t, 100, func(s *config.SecurityConfig) { s.TPAActiveRoot = config.TPARootOperator }) + acc := f.activate(signedCandidate(t, keyOperator, bundleOpts{Seq: 1})) + assert.Equal(t, AuthorityOperator, acc.Authority) + + degraded, reason := computeCoverage(servingFacts{Verified: true, Authority: AuthorityOperator}, f.st, f.policy, 100) + assert.True(t, degraded) + assert.Equal(t, DegradedOperatorAuthorityUnbaselined, reason) + }) +} + +func TestEpochRules(t *testing.T) { + t.Run("publisher epoch ahead of the binary", func(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + rej := f.refuse(signedCandidate(t, keyPublisher, bundleOpts{Seq: 101, Epoch: 1}), RejectEpochAhead) + assert.Contains(t, rej.Detail, "epoch 1") + }) + t.Run("publisher epoch behind the binary", func(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + withTrustAnchors(t, []ed25519.PublicKey{keyPublisher.pub}, 2, nil) + f.repolicy(nil) + f.refuse(signedCandidate(t, keyPublisher, bundleOpts{Seq: 101, Epoch: 1}), RejectStaleEpochReplay) + _, rej := f.eval(signedCandidate(t, keyPublisher, bundleOpts{Seq: 101, Epoch: 2})) + assert.Nil(t, rej, "an exactly equal epoch activates") + }) + t.Run("operator epoch is config-resident", func(t *testing.T) { + f := newPipelineFixture(t, 100, func(s *config.SecurityConfig) { opRoot(10)(s); s.TPAOperatorEpoch = u64p(3) }) + _, rej := f.eval(signedCandidate(t, keyOperator, bundleOpts{Seq: 11, Epoch: 3})) + assert.Nil(t, rej) + f.refuse(signedCandidate(t, keyOperator, bundleOpts{Seq: 11, Epoch: 2}), RejectStaleEpochReplay) + f.refuse(signedCandidate(t, keyOperator, bundleOpts{Seq: 11, Epoch: 4}), RejectEpochAhead) + }) + t.Run("an operator epoch bump re-baselines the operator watermark", func(t *testing.T) { + f := newPipelineFixture(t, 100, opRoot(10)) + f.activate(signedCandidate(t, keyOperator, bundleOpts{Seq: 900})) + f.repolicy(func(s *config.SecurityConfig) { opRoot(10)(s); s.TPAOperatorEpoch = u64p(1) }) + assert.Equal(t, uint64(10), f.auth(AuthorityOperator).Watermark) + _, rej := f.eval(signedCandidate(t, keyOperator, bundleOpts{Seq: 11, Epoch: 1})) + assert.Nil(t, rej) + }) +} + +// --- SC-012: a substituted state file ------------------------------------------------ + +// Only the guarantees anchored OUTSIDE the data directory are asserted here: +// nothing in this test claims the ratchet, watermark or pin survive. +func TestSC012_SubstitutedStateCannotLowerTheAnchors(t *testing.T) { + forge := func(f *pipelineFixture, pinDigest string) { + f.st = &TPABundleState{ + SchemaVersion: 1, + SignatureRatchet: false, // cleared + Authorities: map[string]*TPAAuthorityState{ + AuthorityPublisher: {Epoch: 0, Watermark: 0, Pin: &TPABundlePin{Sequence: 50, FullDigest: pinDigest}}, + AuthorityOperator: {Epoch: 0, Watermark: 0}, + }, + } + f.renormalize() + } + + t.Run("the publisher baseline still refuses an older publisher bundle", func(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + old := signedCandidate(t, keyPublisher, bundleOpts{Seq: 50}) + forge(f, digestOf(old.Bundle)) // ...even with a forged pin naming exactly that release + f.refuse(old, RejectDowngrade) + f.refuse(signedCandidate(t, keyPublisher, bundleOpts{Seq: 3}), RejectDowngrade) + }) + + t.Run("require_signed_bundle still refuses unsigned", func(t *testing.T) { + f := newPipelineFixture(t, 100, func(s *config.SecurityConfig) { s.RequireSignedBundle = true }) + forge(f, "x") + f.refuse(unsignedCandidate(t, bundleOpts{}), RejectSignatureRequired) + }) + + t.Run("the operator baseline still refuses an older operator bundle", func(t *testing.T) { + f := newPipelineFixture(t, 100, opRoot(10)) + forge(f, "x") + f.refuse(signedCandidate(t, keyOperator, bundleOpts{Seq: 4}), RejectDowngrade) + }) + + t.Run("the root declaration still refuses the other authority", func(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + forge(f, "x") + f.refuse(signedCandidate(t, keyOperator, bundleOpts{Seq: 11}), RejectAuthorityNotActive) + }) +} + +// --- applyActivation ---------------------------------------------------------------- + +func TestApplyActivation(t *testing.T) { + now := fixedNow + + t.Run("signed advance", func(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + f.st.Authorities[AuthorityPublisher].Pin = &TPABundlePin{FullDigest: "old-pin"} + c := signedCandidate(t, keyPublisher, bundleOpts{Seq: 150, GeneratedAt: "2026-10-01T00:00:00Z"}) + acc, rej := f.eval(c) + require.Nil(t, rej) + + in := cloneState(f.st) + out, row := applyActivation(f.st, acc, c, now) + assert.Equal(t, in, f.st, "the input is not mutated") + + pub := out.Authorities[AuthorityPublisher] + assert.Equal(t, uint64(150), pub.Watermark) + assert.Equal(t, acc.FullDigest, pub.WatermarkSetBy) + assert.Nil(t, pub.Pin, "a normal advance supersedes the pin") + assert.True(t, pub.ExternalEverActivated) + assert.True(t, out.SignatureRatchet) + assert.Equal(t, &TPABundleActiveRecord{ + Authority: AuthorityPublisher, TrustEpoch: 0, Sequence: 150, + FullDigest: acc.FullDigest, SidecarDigest: acc.SidecarDigest, SignatureVerified: true, + Source: BundleSourceFile, Path: c.Path, GeneratedAt: "2026-10-01T00:00:00Z", ActivatedAt: now, + }, out.Active) + + require.NotNil(t, row) + assert.Equal(t, &TPABundleActivation{ + Authority: AuthorityPublisher, TrustEpoch: 0, Sequence: "150", FullDigest: acc.FullDigest, + GeneratedAt: "2026-10-01T00:00:00Z", ActivatedAt: now, Source: BundleSourceFile, SignatureVerified: true, + }, row) + }) + + t.Run("an activation at or below the watermark leaves it alone and keeps the pin", func(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + target := signedCandidate(t, keyPublisher, bundleOpts{Seq: 120, Tag: "target"}) + f.activate(target) + f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 130, Tag: "newest"})) + f.st.Authorities[AuthorityPublisher].Pin = &TPABundlePin{Sequence: 120, FullDigest: digestOf(target.Bundle)} + + acc, rej := evaluateCandidate(target, f.env(evalLKG)) + require.Nil(t, rej, "the pinned target re-validates") + out, _ := applyActivation(f.st, acc, target, now) + pub := out.Authorities[AuthorityPublisher] + assert.Equal(t, uint64(130), pub.Watermark) + assert.NotNil(t, pub.Pin) + }) + + t.Run("unsigned never touches the watermark, the ratchet or the authority records", func(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + c := unsignedCandidate(t, bundleOpts{SeqRaw: `"18446744073709551615"`}) + acc, rej := f.eval(c) + require.Nil(t, rej) + + out, row := applyActivation(f.st, acc, c, now) + assert.Equal(t, f.st.Authorities, out.Authorities) + assert.False(t, out.SignatureRatchet) + require.NotNil(t, out.Active) + assert.Equal(t, "", out.Active.Authority) + assert.False(t, out.Active.SignatureVerified) + assert.Equal(t, uint64(0), out.Active.Sequence) + require.NotNil(t, row) + assert.Equal(t, "", row.Sequence) + assert.False(t, row.SignatureVerified) + assert.Equal(t, "", row.Authority) + }) + + t.Run("an operator activation scopes to the operator record", func(t *testing.T) { + f := newPipelineFixture(t, 100, opRoot(10)) + c := signedCandidate(t, keyOperator, bundleOpts{Seq: 11}) + acc, rej := f.eval(c) + require.Nil(t, rej) + out, row := applyActivation(f.st, acc, c, now) + assert.Equal(t, uint64(11), out.Authorities[AuthorityOperator].Watermark) + assert.Equal(t, uint64(100), out.Authorities[AuthorityPublisher].Watermark) + assert.True(t, out.Authorities[AuthorityOperator].ExternalEverActivated) + assert.False(t, out.Authorities[AuthorityPublisher].ExternalEverActivated) + assert.Equal(t, AuthorityOperator, row.Authority) + }) +} + +// --- applyRollback + the rollback flow ------------------------------------------------ + +func TestRollbackToEmbedded(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + r101 := signedCandidate(t, keyPublisher, bundleOpts{Seq: 101, Tag: "r101"}) + r102 := signedCandidate(t, keyPublisher, bundleOpts{Seq: 102, Tag: "r102"}) + f.activate(r101) + f.activate(r102) + + embeddedDigest := digestOf(bundledScannerBundle) + target := embeddedRollbackTarget(f.policy, embeddedDigest, 100, "") + assert.True(t, target.Embedded) + assert.Equal(t, AuthorityPublisher, target.Authority) + assert.Equal(t, BundleSourceEmbedded, target.Source) + + away := f.st.Active + require.NotNil(t, away) + in := cloneState(f.st) + out, row := applyRollback(f.st, f.policy, target, away, fixedNow) + assert.Equal(t, in, f.st, "the input is not mutated") + + pub := out.Authorities[AuthorityPublisher] + assert.Equal(t, uint64(102), pub.Watermark, "rollback never lowers the watermark") + assert.Equal(t, digestOf(r102.Bundle), pub.WatermarkSetBy) + assert.Equal(t, []TPABundleDenyEntry{{Sequence: 102, FullDigest: digestOf(r102.Bundle), DeniedAt: fixedNow}}, pub.DenyList) + require.NotNil(t, pub.Pin) + assert.True(t, pub.Pin.Embedded) + assert.Equal(t, embeddedDigest, pub.Pin.FullDigest) + assert.Equal(t, uint64(100), pub.Pin.Sequence) + assert.Nil(t, out.Active, "the embedded default serves") + require.NotNil(t, row) + assert.True(t, row.Rollback) + assert.Equal(t, BundleSourceEmbedded, row.Source) + assert.Equal(t, AuthorityPublisher, row.Authority) + assert.Equal(t, embeddedDigest, row.FullDigest) + assert.Equal(t, "100", row.Sequence) + + // The next refresh: the rolled-away release is refused, intermediates too. + f.st = out + f.renormalize() + rej := f.refuse(r102, RejectDenyListed) + assert.NotEmpty(t, rej.Detail) + f.refuse(r101, RejectDowngrade) + + // A genuinely newer release clears the pin and activates. + f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 103})) + assert.Nil(t, f.auth(AuthorityPublisher).Pin) + assert.Len(t, f.auth(AuthorityPublisher).DenyList, 1, "the deny entry stays for its epoch") +} + +func TestRollbackToNamedFile(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + r101 := signedCandidate(t, keyPublisher, bundleOpts{Seq: 101, Tag: "r101"}) + r102 := signedCandidate(t, keyPublisher, bundleOpts{Seq: 102, Tag: "r102"}) + f.activate(r101) + f.activate(r102) + + // Without rollback mode the older release is a downgrade... + f.refuse(r101, RejectDowngrade) + + // ...with it, and the operator's exact digest, it activates below the watermark. + env := f.env(evalRollback) + env.RollbackDigest = digestOf(r101.Bundle) + acc, rej := evaluateCandidate(r101, env) + require.Nil(t, rej) + + away := f.st.Active + out, row := applyRollback(f.st, f.policy, rollbackTargetFromAccepted(acc, r101), away, fixedNow) + pub := out.Authorities[AuthorityPublisher] + assert.Equal(t, uint64(102), pub.Watermark) + assert.Equal(t, digestOf(r102.Bundle), pub.WatermarkSetBy) + require.NotNil(t, pub.Pin) + assert.Equal(t, digestOf(r101.Bundle), pub.Pin.FullDigest) + assert.False(t, pub.Pin.Embedded) + assert.Equal(t, uint64(101), pub.Pin.Sequence) + require.Len(t, pub.DenyList, 1) + assert.Equal(t, digestOf(r102.Bundle), pub.DenyList[0].FullDigest) + + require.NotNil(t, out.Active) + assert.True(t, out.Active.Rollback) + assert.Equal(t, digestOf(r101.Bundle), out.Active.FullDigest) + assert.True(t, out.Active.SignatureVerified) + assert.Equal(t, uint64(101), out.Active.Sequence) + assert.True(t, row.Rollback) + assert.Equal(t, "101", row.Sequence) + assert.True(t, row.SignatureVerified) + + // Restart: the pinned target re-validates, the rolled-away release does not. + f.st = out + f.renormalize() + _, rej = evaluateCandidate(r101, f.env(evalLKG)) + assert.Nil(t, rej, "the pinned target re-validates in lkg mode") + f.refuse(r102, RejectDenyListed) + + // Rolling away from the same release twice does not duplicate the entry. + again, _ := applyRollback(out, f.policy, rollbackTargetFromAccepted(acc, r101), &TPABundleActiveRecord{ + Authority: AuthorityPublisher, TrustEpoch: 0, Sequence: 102, FullDigest: digestOf(r102.Bundle), SignatureVerified: true, + }, fixedNow.Add(time.Hour)) + assert.Len(t, again.Authorities[AuthorityPublisher].DenyList, 1) +} + +func TestRollback_DenyListOnlyForSignedReleasesOfThisEpoch(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + target := embeddedRollbackTarget(f.policy, digestOf(bundledScannerBundle), 100, "") + + t.Run("rolling away from an unsigned bundle denies nothing", func(t *testing.T) { + out, _ := applyRollback(f.st, f.policy, target, &TPABundleActiveRecord{FullDigest: "u", Source: BundleSourceFile}, fixedNow) + assert.Empty(t, out.Authorities[AuthorityPublisher].DenyList) + assert.Empty(t, out.Authorities[AuthorityOperator].DenyList) + }) + t.Run("rolling away from the embedded default denies nothing", func(t *testing.T) { + out, _ := applyRollback(f.st, f.policy, target, nil, fixedNow) + assert.Empty(t, out.Authorities[AuthorityPublisher].DenyList) + }) + t.Run("a release of a retired epoch is not denied", func(t *testing.T) { + out, _ := applyRollback(f.st, f.policy, target, &TPABundleActiveRecord{ + Authority: AuthorityPublisher, TrustEpoch: 7, Sequence: 3, FullDigest: "old", SignatureVerified: true, + }, fixedNow) + assert.Empty(t, out.Authorities[AuthorityPublisher].DenyList) + }) +} + +func TestRollback_BaselineIsMeasuredAgainstTheTargetsOwnAuthority(t *testing.T) { + t.Run("a publisher target below the embedded corpus", func(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + old := signedCandidate(t, keyPublisher, bundleOpts{Seq: 90}) + env := f.env(evalRollback) + env.RollbackDigest = digestOf(old.Bundle) + _, rej := evaluateCandidate(old, env) + require.NotNil(t, rej) + assert.Equal(t, RejectDowngrade, rej.Reason) + assert.Contains(t, rej.Detail, "below publisher baseline 100") + }) + + t.Run("an operator target at sequence 9 is fine against a publisher corpus at 100", func(t *testing.T) { + f := newPipelineFixture(t, 100, opRoot(5)) + f.activate(signedCandidate(t, keyOperator, bundleOpts{Seq: 20, Tag: "now"})) + tgt := signedCandidate(t, keyOperator, bundleOpts{Seq: 9, Tag: "target"}) + env := f.env(evalRollback) + env.RollbackDigest = digestOf(tgt.Bundle) + _, rej := evaluateCandidate(tgt, env) + assert.Nil(t, rej) + + below := signedCandidate(t, keyOperator, bundleOpts{Seq: 4, Tag: "below"}) + env.RollbackDigest = digestOf(below.Bundle) + _, rej = evaluateCandidate(below, env) + require.NotNil(t, rej) + assert.Contains(t, rej.Detail, "below operator baseline 5") + }) + + t.Run("a baseline-less operator rollback proceeds (degraded)", func(t *testing.T) { + f := newPipelineFixture(t, 100, func(s *config.SecurityConfig) { s.TPAActiveRoot = config.TPARootOperator }) + f.activate(signedCandidate(t, keyOperator, bundleOpts{Seq: 20, Tag: "now"})) + tgt := signedCandidate(t, keyOperator, bundleOpts{Seq: 2, Tag: "target"}) + env := f.env(evalRollback) + env.RollbackDigest = digestOf(tgt.Bundle) + acc, rej := evaluateCandidate(tgt, env) + require.Nil(t, rej) + out, _ := applyRollback(f.st, f.policy, rollbackTargetFromAccepted(acc, tgt), f.st.Active, fixedNow) + f.st = out + f.renormalize() + degraded, reason := computeCoverage(servingFacts{Verified: true, Authority: AuthorityOperator}, f.st, f.policy, 100) + assert.True(t, degraded) + assert.Equal(t, DegradedOperatorAuthorityUnbaselined, reason) + }) +} + +func TestRollback_PinSuspendedWhileTheOtherRootIsActive(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + r101 := signedCandidate(t, keyPublisher, bundleOpts{Seq: 101}) + f.activate(r101) + f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 102})) + out, _ := applyRollback(f.st, f.policy, embeddedRollbackTarget(f.policy, digestOf(bundledScannerBundle), 100, ""), f.st.Active, fixedNow) + f.st = out + + // Switching to the operator root leaves the publisher pin in place (suspended, + // not deleted), and the operator authority's own rules apply. + f.repolicy(opRoot(10)) + assert.NotNil(t, f.auth(AuthorityPublisher).Pin) + f.activate(signedCandidate(t, keyOperator, bundleOpts{Seq: 11})) + assert.NotNil(t, f.auth(AuthorityPublisher).Pin) +} + +// --- computeCoverage ---------------------------------------------------------------------- + +func TestComputeCoverage_Degrades(t *testing.T) { + t.Run("unsigned external active", func(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + d, r := computeCoverage(servingFacts{}, f.st, f.policy, 100) + assert.True(t, d) + assert.Equal(t, DegradedUnsignedExternalActive, r) + }) + + t.Run("operator authority unbaselined", func(t *testing.T) { + f := newPipelineFixture(t, 100, func(s *config.SecurityConfig) { s.TPAActiveRoot = config.TPARootOperator }) + d, r := computeCoverage(servingFacts{Verified: true, Authority: AuthorityOperator}, f.st, f.policy, 100) + assert.True(t, d) + assert.Equal(t, DegradedOperatorAuthorityUnbaselined, r) + }) + + t.Run("embedded below the publisher watermark", func(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 150})) + d, r := computeCoverage(servingFacts{Embedded: true}, f.st, f.policy, 100) + assert.True(t, d) + assert.Equal(t, DegradedEmbeddedBelowWatermark, r) + }) + + t.Run("operator root declared and its bundle is gone", func(t *testing.T) { + f := newPipelineFixture(t, 100, opRoot(10)) + f.activate(signedCandidate(t, keyOperator, bundleOpts{Seq: 11})) + d, r := computeCoverage(servingFacts{Embedded: true}, f.st, f.policy, 100) + assert.True(t, d) + assert.Equal(t, DegradedOperatorBundleMissing, r) + }) + + t.Run("the first matching condition wins", func(t *testing.T) { + // Unsigned AND (irrelevantly) operator-authority: rule 1 reports first. + f := newPipelineFixture(t, 100, func(s *config.SecurityConfig) { s.TPAActiveRoot = config.TPARootOperator }) + _, r := computeCoverage(servingFacts{Authority: AuthorityOperator}, f.st, f.policy, 100) + assert.Equal(t, DegradedUnsignedExternalActive, r) + + // Embedded below the publisher watermark AND operator bundle missing: rule 3 first. + g := newPipelineFixture(t, 100, opRoot(10)) + g.activate(signedCandidate(t, keyOperator, bundleOpts{Seq: 11})) + g.st.Authorities[AuthorityPublisher].Watermark = 500 + _, r = computeCoverage(servingFacts{Embedded: true}, g.st, g.policy, 100) + assert.Equal(t, DegradedEmbeddedBelowWatermark, r) + }) +} + +func TestComputeCoverage_MustNotDegrade(t *testing.T) { + t.Run("a fresh install serving the embedded default", func(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + d, r := computeCoverage(servingFacts{Embedded: true}, f.st, f.policy, 100) + assert.False(t, d) + assert.Equal(t, "", r) + }) + + t.Run("a nil state is a fresh install", func(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + d, _ := computeCoverage(servingFacts{Embedded: true}, nil, f.policy, 100) + assert.False(t, d) + }) + + t.Run("embedded at or above the publisher watermark after the external file was removed", func(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 150})) + // A binary upgrade shipped a newer corpus; the old external file is gone. + d, _ := computeCoverage(servingFacts{Embedded: true}, f.st, f.policy, 150) + assert.False(t, d, "equal to the watermark") + d, _ = computeCoverage(servingFacts{Embedded: true}, f.st, f.policy, 200) + assert.False(t, d, "above the watermark") + }) + + t.Run("operator root declared but nothing ever activated", func(t *testing.T) { + f := newPipelineFixture(t, 100, opRoot(10)) + d, _ := computeCoverage(servingFacts{Embedded: true}, f.st, f.policy, 100) + assert.False(t, d) + }) + + t.Run("operator watermark 900 against an embedded publisher corpus at 100", func(t *testing.T) { + f := newPipelineFixture(t, 100, opRoot(10)) + f.activate(signedCandidate(t, keyOperator, bundleOpts{Seq: 900})) + // Operator authority serving its own baselined bundle: full coverage. + d, _ := computeCoverage(servingFacts{Verified: true, Authority: AuthorityOperator}, f.st, f.policy, 100) + assert.False(t, d) + // Publisher root declared (operator bundle deliberately switched away from): + // the embedded fallback is compared only to the PUBLISHER watermark. + f.repolicy(func(s *config.SecurityConfig) { s.TPAOperatorBaseline = u64p(10) }) + d, _ = computeCoverage(servingFacts{Embedded: true}, f.st, f.policy, 100) + assert.False(t, d, "no cross-authority comparison anywhere") + }) + + t.Run("a verified publisher bundle", func(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 150})) + d, _ := computeCoverage(servingFacts{Verified: true, Authority: AuthorityPublisher}, f.st, f.policy, 100) + assert.False(t, d) + }) + + t.Run("a missing publisher bundle never triggers operator_bundle_missing", func(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 100 + 1})) + d, _ := computeCoverage(servingFacts{Embedded: true}, f.st, f.policy, 101) + assert.False(t, d) + }) + + t.Run("an invalid active root follows the same four rules", func(t *testing.T) { + f := newPipelineFixture(t, 100, func(s *config.SecurityConfig) { s.TPAActiveRoot = "bogus" }) + d, _ := computeCoverage(servingFacts{Embedded: true}, f.st, f.policy, 100) + assert.False(t, d, "embedded fallback on a fresh state is full coverage") + }) +} + +func TestStateLogicTestsDoNotLeakAcrossFixtures(t *testing.T) { + // A guard for the helper itself: two fixtures never share state. + a := newPipelineFixture(t, 100, nil) + b := newPipelineFixture(t, 100, nil) + a.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 150})) + assert.Equal(t, uint64(100), b.auth(AuthorityPublisher).Watermark) + assert.False(t, strings.Contains(a.policy.Fingerprint, " ")) +} diff --git a/internal/security/scanner/tpa_candidate.go b/internal/security/scanner/tpa_candidate.go new file mode 100644 index 000000000..ea995a045 --- /dev/null +++ b/internal/security/scanner/tpa_candidate.go @@ -0,0 +1,301 @@ +package scanner + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/security/bundlesig" +) + +// Candidate is one bundle offered for activation, independent of where it came +// from. The file-drop adapter builds it from two bounded reads; Spec 087's +// fetch adapter builds the same struct from a download (FR-018). Everything the +// pipeline decides is a function of these bytes and the evalEnv. +type Candidate struct { + // Bundle is the exact candidate bytes. They are verified and then parsed + // from this one buffer (FR-006): nothing re-reads Path. + Bundle []byte + // Sidecar is the detached-signature sidecar; meaningful only when + // SidecarPresent. A present-but-invalid sidecar is a rejection, never a + // fallback to the unsigned path (tasks.md A7). + Sidecar []byte + SidecarPresent bool + // Source is the BundleSource* label; Path is informational only. + Source string + Path string +} + +// evalMode says how the anti-downgrade rules treat a candidate. +type evalMode int + +const ( + // evalNormal is a freshly offered candidate. + evalNormal evalMode = iota + // evalLKG re-validates the bundle already recorded as active. The + // downgradeCheck allow-rules already admit it, so it differs from evalNormal + // only in intent. + evalLKG + // evalRollback is an explicit operator rollback (FR-010): the one mode + // that may land at or below the watermark, and only on the exact digest the + // operator named. + evalRollback +) + +// evalEnv is everything evaluateCandidate may consult besides the candidate. +type evalEnv struct { + Policy *bundlePolicy + // State is the NORMALIZED state (normalizeState); nil is treated as empty. + State *TPABundleState + Mode evalMode + // RollbackDigest is the full digest the operator named (evalRollback only). + RollbackDigest string + // Now stamps rejections; nil means time.Now. + Now func() time.Time +} + +// acceptedBundle is a candidate that passed every stage. +type acceptedBundle struct { + Check *BundleCheck + Stats bundleLoadStats + // Meta is the manifest header (versions, signature count, generated_at). + Meta bundleMeta + // FullDigest is the lowercase-hex SHA-256 of the bundle bytes; its first 12 + // characters are the human Fingerprint. + FullDigest string + // SidecarDigest is the SHA-256 of the sidecar bytes ("" when unsigned). + SidecarDigest string + // Authority is the verifying key's authority; "" for an unsigned candidate. + Authority string + // Verified is true when the detached signature verified. + Verified bool + // Sequence and TrustEpoch come from the SIGNED manifest; both are zero for + // an unsigned candidate, whose manifest values are attacker-chosen. + Sequence uint64 + TrustEpoch uint64 + // KeyID is the id of the key that verified ("" when unsigned). + KeyID string +} + +// bundleSelfCheck is the activation self-check stage hook (FR-008; tasks.md +// Δ14). The default (nil) is a no-op; a non-nil error refuses the candidate as +// gate_failure. The real self-check is Spec 087 / US2 D8. +var bundleSelfCheck func(check *BundleCheck) error + +// evaluateCandidate runs the FR-008 validate-before-activate stages in order; +// the first failure wins and every failure leaves last-known-good serving. +// +// The signed path verifies the detached signature over the raw bytes BEFORE +// the bytes are parsed or any pattern is compiled, and then parses the very +// buffer that was verified (FR-006): evaluateCandidate never reads c.Path. +func evaluateCandidate(c Candidate, env evalEnv) (*acceptedBundle, *BundleRejection) { + ev := evaluation{c: c, env: env, policy: env.Policy, digest: digestHex(c.Bundle)} + if ev.policy == nil { + return nil, ev.reject(RejectInvalidBundle, "no bundle policy") + } + if c.SidecarPresent { + return ev.signed() + } + return ev.unsigned() +} + +// evaluation carries one evaluateCandidate call's working values. +type evaluation struct { + c Candidate + env evalEnv + policy *bundlePolicy + digest string +} + +func (ev *evaluation) reject(reason, detail string) *BundleRejection { + now := time.Now + if ev.env.Now != nil { + now = ev.env.Now + } + return &BundleRejection{Reason: reason, Detail: detail, At: now()} +} + +// checkBundleSize enforces the bundle byte ceiling on the raw bytes. The file +// reader already caps what it reads, but a Candidate is source-neutral (Spec +// 087's fetch adapter builds the same struct), so the pipeline enforces it too. +func (ev *evaluation) checkBundleSize() *BundleRejection { + if len(ev.c.Bundle) > MaxBundleBytes { + return ev.reject(RejectCeilingExceeded, fmt.Sprintf("bundle: %d bytes > %d", len(ev.c.Bundle), MaxBundleBytes)) + } + return nil +} + +// parseAndCeil is the ONE parse of the candidate buffer followed by the +// rule-count ceiling, shared by both paths. +func (ev *evaluation) parseAndCeil() (*rawBundle, *BundleRejection) { + m, err := parseBundleManifest(ev.c.Bundle) + if err != nil { + return nil, ev.reject(RejectInvalidBundle, err.Error()) + } + if err := checkRuleCeiling(m); err != nil { + return nil, ev.reject(RejectCeilingExceeded, err.Error()) + } + return m, nil +} + +// compileAndGate runs the tail every accepted candidate shares: version +// compatibility, compile-all-or-reject, runnable-rules > 0, then the +// activation self-check. +func (ev *evaluation) compileAndGate(m *rawBundle) (*BundleCheck, bundleLoadStats, *BundleRejection) { + check, stats, err := compileBundle(m) + if err != nil { + var ce *bundleCeilingError + if errors.As(err, &ce) { + return nil, bundleLoadStats{}, ev.reject(RejectCeilingExceeded, err.Error()) + } + return nil, bundleLoadStats{}, ev.reject(RejectInvalidBundle, err.Error()) + } + if bundleSelfCheck != nil { + if err := bundleSelfCheck(check); err != nil { + return nil, bundleLoadStats{}, ev.reject(RejectGateFailure, err.Error()) + } + } + return check, stats, nil +} + +func (ev *evaluation) accepted(m *rawBundle, check *BundleCheck, stats bundleLoadStats) *acceptedBundle { + return &acceptedBundle{ + Check: check, + Stats: stats, + Meta: bundleMeta{ + BundleVersion: m.BundleVersion, + SchemaVersion: m.SchemaVersion, + SignatureCount: m.SignatureCount, + GeneratedAt: m.GeneratedAt, + }, + FullDigest: ev.digest, + } +} + +// unsigned is the path of a candidate with no sidecar (tasks.md A6): its +// manifest is attacker-chosen, so its sequence, epoch and key_id are never +// consulted, it never advances the watermark, and it activates degraded. +func (ev *evaluation) unsigned() (*acceptedBundle, *BundleRejection) { + if ev.policy.RequireSigned { + return nil, ev.reject(RejectSignatureRequired, "require_signed_bundle is on and the candidate has no signature sidecar") + } + if ev.env.State != nil && ev.env.State.SignatureRatchet { + return nil, ev.reject(RejectUnsignedRatchet, "a signature-verified bundle has activated on this install; unsigned candidates are refused") + } + if rej := ev.checkBundleSize(); rej != nil { + return nil, rej + } + m, rej := ev.parseAndCeil() + if rej != nil { + return nil, rej + } + check, stats, rej := ev.compileAndGate(m) + if rej != nil { + return nil, rej + } + return ev.accepted(m, check, stats), nil +} + +// signed is the path of a candidate that carries a sidecar. A present sidecar +// that cannot be used is a rejection and never a fallback to unsigned (A7). +func (ev *evaluation) signed() (*acceptedBundle, *BundleRejection) { + c, p := ev.c, ev.policy + + sc, err := bundlesig.ParseSidecar(c.Sidecar) + if err != nil { + code, detail := bundlesig.CodeOf(err), err.Error() + var be *bundlesig.Error + if errors.As(err, &be) { + detail = be.Detail + } + if code == bundlesig.CodeSidecarTooLarge { + return nil, ev.reject(RejectCeilingExceeded, "sidecar: "+detail) + } + return nil, ev.reject(RejectSidecarInvalid, code) + } + + if rej := ev.checkBundleSize(); rej != nil { + return nil, rej + } + + if p.Trust == nil { + return nil, ev.reject(RejectKeyNotTrusted, sc.KeyID) + } + key, err := p.Trust.Verify(c.Bundle, sc) + if err != nil { + if bundlesig.CodeOf(err) == bundlesig.CodeKeyNotTrusted { + return nil, ev.reject(RejectKeyNotTrusted, sc.KeyID) + } + return nil, ev.reject(RejectTamper, err.Error()) + } + if key.Authority != p.ActiveRoot { + return nil, ev.reject(RejectAuthorityNotActive, + fmt.Sprintf("verified by %s key %s; active root is %s", key.Authority, key.ID, p.ActiveRoot)) + } + + // From here the bytes are authenticated: parse the SAME buffer once. + m, rej := ev.parseAndCeil() + if rej != nil { + return nil, rej + } + seq, _, err := parseSequence(m.Sequence) + if err != nil { + return nil, ev.reject(RejectInvalidBundle, err.Error()) + } + epoch, err := parseTrustEpoch(m.TrustEpoch) + if err != nil { + return nil, ev.reject(RejectInvalidBundle, err.Error()) + } + if err := bundlesig.CheckKeyIDAgreement(m.KeyID, sc, key); err != nil { + return nil, ev.reject(RejectTamper, err.Error()) + } + + auth := key.Authority + cur := p.currentEpoch(auth) + switch { + case epoch < cur: + return nil, ev.reject(RejectStaleEpochReplay, fmt.Sprintf("manifest epoch %d, current %s epoch %d", epoch, auth, cur)) + case epoch > cur: + return nil, ev.reject(RejectEpochAhead, fmt.Sprintf("manifest epoch %d, current %s epoch %d; update mcpproxy or the operator epoch", epoch, auth, cur)) + } + + var a *TPAAuthorityState + if ev.env.State != nil { + a = ev.env.State.Authorities[auth] + } + if denied(a, seq, ev.digest) { + return nil, ev.reject(RejectDenyListed, fmt.Sprintf("release %s (sequence %d) was rolled away from", shortDigest(ev.digest), seq)) + } + if detail := downgradeCheck(a, auth, p, seq, ev.digest, ev.env.Mode, ev.env.RollbackDigest); detail != "" { + return nil, ev.reject(RejectDowngrade, detail) + } + + check, stats, rej := ev.compileAndGate(m) + if rej != nil { + return nil, rej + } + acc := ev.accepted(m, check, stats) + acc.SidecarDigest = digestHex(c.Sidecar) + acc.Authority = auth + acc.Verified = true + acc.Sequence = seq + acc.TrustEpoch = epoch + acc.KeyID = key.ID + return acc, nil +} + +// digestHex is the lowercase-hex SHA-256 of b. +func digestHex(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +// shortDigest is the 12-hex human fingerprint of a full digest. +func shortDigest(d string) string { + if len(d) > 12 { + return d[:12] + } + return d +} diff --git a/internal/security/scanner/tpa_candidate_test.go b/internal/security/scanner/tpa_candidate_test.go new file mode 100644 index 000000000..360d5f7c9 --- /dev/null +++ b/internal/security/scanner/tpa_candidate_test.go @@ -0,0 +1,560 @@ +package scanner + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/security/bundlesig" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/security/detect" +) + +// countCompiles installs the compile counter seam for one test. +func countCompiles(t *testing.T) *int { + t.Helper() + n := 0 + bundleCompileHook = func() { n++ } + t.Cleanup(func() { bundleCompileHook = nil }) + return &n +} + +// withSelfCheck installs the activation self-check hook for one test. +func withSelfCheck(t *testing.T, fn func(*BundleCheck) error) { + t.Helper() + prev := bundleSelfCheck + bundleSelfCheck = fn + t.Cleanup(func() { bundleSelfCheck = prev }) +} + +// flipByte returns a copy of b with the byte at the first occurrence of marker +// altered so the result is still valid JSON of the same length. +func flipByte(t *testing.T, b []byte, marker string) []byte { + t.Helper() + i := bytes.Index(b, []byte(marker)) + require.GreaterOrEqual(t, i, 0) + out := append([]byte(nil), b...) + out[i] ^= 0x01 // 'b' -> 'c', 'x' -> 'y': still printable ASCII inside a string + require.NotEqual(t, b, out) + return out +} + +// --- T060: stage order ------------------------------------------------------- + +// TestEvaluateCandidate_StageOrder drives every FR-008 stage with a candidate +// that ALSO fails every later stage it can, and requires the earliest stage's +// reason. A reordering of any two adjacent stages flips one of these. +func TestEvaluateCandidate_StageOrder(t *testing.T) { + // Publisher baseline 100 (embedded sequence), publisher epoch 0. + f := newPipelineFixture(t, 100, nil) + garbage := []byte("this is not json at all") + + t.Run("sidecar grammar beats everything", func(t *testing.T) { + f.refuse(Candidate{Bundle: garbage, Sidecar: []byte("{}"), SidecarPresent: true}, RejectSidecarInvalid) + }) + + t.Run("sidecar byte cap beats bundle handling", func(t *testing.T) { + big := append(bundlesig.SignSidecar(keyPublisher.priv, garbage), bytes.Repeat([]byte(" "), MaxSidecarBytes)...) + rej := f.refuse(Candidate{Bundle: garbage, Sidecar: big, SidecarPresent: true}, RejectCeilingExceeded) + assert.Contains(t, rej.Detail, "sidecar") + }) + + t.Run("bundle byte ceiling beats the key lookup", func(t *testing.T) { + huge := bytes.Repeat([]byte("a"), MaxBundleBytes+1) + rej := f.refuse(Candidate{Bundle: huge, Sidecar: signBundle(t, keyUntrusted.priv, huge), SidecarPresent: true}, RejectCeilingExceeded) + assert.Contains(t, rej.Detail, "bundle") + }) + + t.Run("key lookup beats parsing", func(t *testing.T) { + rej := f.refuse(Candidate{Bundle: garbage, Sidecar: signBundle(t, keyUntrusted.priv, garbage), SidecarPresent: true}, RejectKeyNotTrusted) + assert.Contains(t, rej.Detail, keyUntrusted.id) + }) + + t.Run("signature beats parsing", func(t *testing.T) { + sc := signBundle(t, keyPublisher.priv, []byte("something else")) + f.refuse(Candidate{Bundle: garbage, Sidecar: sc, SidecarPresent: true}, RejectTamper) + }) + + t.Run("authority beats parsing", func(t *testing.T) { + rej := f.refuse(Candidate{Bundle: garbage, Sidecar: signBundle(t, keyOperator.priv, garbage), SidecarPresent: true}, RejectAuthorityNotActive) + assert.Contains(t, rej.Detail, AuthorityOperator) + assert.Contains(t, rej.Detail, keyOperator.id) + assert.Contains(t, rej.Detail, "active root is publisher") + }) + + t.Run("manifest parse", func(t *testing.T) { + f.refuse(Candidate{Bundle: garbage, Sidecar: signBundle(t, keyPublisher.priv, garbage), SidecarPresent: true}, RejectInvalidBundle) + }) + + t.Run("rule ceiling beats the sequence grammar", func(t *testing.T) { + // 2001 rules AND no sequence at all. + c := signedCandidate(t, keyPublisher, bundleOpts{Rules: MaxBundleRules + 1}) + rej := f.refuse(c, RejectCeilingExceeded) + assert.Contains(t, rej.Detail, "rules") + }) + + t.Run("sequence grammar beats key id agreement", func(t *testing.T) { + c := signedCandidate(t, keyPublisher, bundleOpts{SeqRaw: "150", KeyID: keyOperator.id}) + f.refuse(c, RejectInvalidBundle) + }) + + t.Run("epoch grammar beats key id agreement", func(t *testing.T) { + c := signedCandidate(t, keyPublisher, bundleOpts{Seq: 150, OmitEpoch: true, KeyID: keyOperator.id}) + f.refuse(c, RejectInvalidBundle) + }) + + t.Run("key id agreement beats the epoch comparison", func(t *testing.T) { + c := signedCandidate(t, keyPublisher, bundleOpts{Seq: 150, Epoch: 9, KeyID: keyOperator.id}) + f.refuse(c, RejectTamper) + }) + + t.Run("epoch beats the watermark", func(t *testing.T) { + // epoch ahead AND below the baseline (150 vs 100 is fine; use 50). + c := signedCandidate(t, keyPublisher, bundleOpts{Seq: 50, Epoch: 1}) + f.refuse(c, RejectEpochAhead) + }) + + t.Run("deny-list beats the watermark", func(t *testing.T) { + c := signedCandidate(t, keyPublisher, bundleOpts{Seq: 50}) + g := newPipelineFixture(t, 100, nil) + g.st.Authorities[AuthorityPublisher].DenyList = []TPABundleDenyEntry{{Sequence: 50, FullDigest: digestOf(c.Bundle)}} + g.refuse(c, RejectDenyListed) + }) + + t.Run("watermark beats version", func(t *testing.T) { + c := signedCandidate(t, keyPublisher, bundleOpts{Seq: 50, Version: "9.9.0"}) + f.refuse(c, RejectDowngrade) + }) + + t.Run("version beats compile", func(t *testing.T) { + n := countCompiles(t) + c := signedCandidate(t, keyPublisher, bundleOpts{Seq: 150, Version: "9.9.0", BadPattern: true}) + f.refuse(c, RejectInvalidBundle) + assert.Zero(t, *n, "an unsupported version is refused before any pattern compiles") + }) + + t.Run("compile failure", func(t *testing.T) { + f.refuse(signedCandidate(t, keyPublisher, bundleOpts{Seq: 150, BadPattern: true}), RejectInvalidBundle) + }) + + t.Run("no runnable rules", func(t *testing.T) { + rej := f.refuse(signedCandidate(t, keyPublisher, bundleOpts{Seq: 150, NoRunnable: true}), RejectInvalidBundle) + assert.Contains(t, rej.Detail, "no runnable rules") + }) + + t.Run("compile failure beats the self-check", func(t *testing.T) { + called := false + withSelfCheck(t, func(*BundleCheck) error { called = true; return errors.New("nope") }) + f.refuse(signedCandidate(t, keyPublisher, bundleOpts{Seq: 150, BadPattern: true}), RejectInvalidBundle) + assert.False(t, called) + }) + + t.Run("self-check last", func(t *testing.T) { + withSelfCheck(t, func(*BundleCheck) error { return errors.New("canary tool flagged clean") }) + rej := f.refuse(signedCandidate(t, keyPublisher, bundleOpts{Seq: 150}), RejectGateFailure) + assert.Contains(t, rej.Detail, "canary tool flagged clean") + }) + + t.Run("a candidate that clears every stage is accepted", func(t *testing.T) { + acc, rej := f.eval(signedCandidate(t, keyPublisher, bundleOpts{Seq: 150})) + require.Nil(t, rej) + require.NotNil(t, acc) + assert.Equal(t, uint64(150), acc.Sequence) + }) +} + +func TestEvaluateCandidate_UnsignedStageOrder(t *testing.T) { + garbage := Candidate{Bundle: []byte("not json"), Source: BundleSourceFile} + + t.Run("require_signed_bundle beats the ratchet and the parse", func(t *testing.T) { + f := newPipelineFixture(t, 100, func(s *config.SecurityConfig) { s.RequireSignedBundle = true }) + f.st.SignatureRatchet = true + f.refuse(garbage, RejectSignatureRequired) + }) + + t.Run("ratchet beats the parse", func(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + f.st.SignatureRatchet = true + f.refuse(garbage, RejectUnsignedRatchet) + }) + + t.Run("parse", func(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + f.refuse(garbage, RejectInvalidBundle) + }) + + t.Run("rule ceiling beats version", func(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + rej := f.refuse(unsignedCandidate(t, bundleOpts{Rules: MaxBundleRules + 1, Version: "9.9.0"}), RejectCeilingExceeded) + assert.Contains(t, rej.Detail, "2001") + }) + + t.Run("version, compile, runnable", func(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + f.refuse(unsignedCandidate(t, bundleOpts{Version: "9.9.0"}), RejectInvalidBundle) + f.refuse(unsignedCandidate(t, bundleOpts{BadPattern: true}), RejectInvalidBundle) + f.refuse(unsignedCandidate(t, bundleOpts{NoRunnable: true}), RejectInvalidBundle) + }) + + t.Run("self-check", func(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + withSelfCheck(t, func(*BundleCheck) error { return errors.New("boom") }) + f.refuse(unsignedCandidate(t, bundleOpts{}), RejectGateFailure) + }) + + t.Run("the byte ceiling applies to unsigned candidates too", func(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + f.refuse(Candidate{Bundle: bytes.Repeat([]byte("a"), MaxBundleBytes+1), Source: BundleSourceFile}, RejectCeilingExceeded) + }) +} + +// An unsigned candidate is accepted unverified, with NO sequence or epoch: its +// manifest is attacker-chosen, so nothing in it may reach the watermark. +func TestEvaluateCandidate_UnsignedIgnoresManifestSequence(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + c := unsignedCandidate(t, bundleOpts{SeqRaw: `"18446744073709551615"`, Epoch: 99, KeyID: keyPublisher.id}) + acc, rej := f.eval(c) + require.Nil(t, rej) + require.NotNil(t, acc) + + assert.False(t, acc.Verified) + assert.Equal(t, "", acc.Authority) + assert.Equal(t, uint64(0), acc.Sequence) + assert.Equal(t, uint64(0), acc.TrustEpoch) + assert.Equal(t, "", acc.KeyID) + assert.Equal(t, digestOf(c.Bundle), acc.FullDigest) + assert.Equal(t, "", acc.SidecarDigest) +} + +// A sidecar that is PRESENT but unusable is a rejection; it must never fall +// back to the unsigned path, even with require_signed_bundle off (tasks.md A7). +func TestEvaluateCandidate_PresentButInvalidSidecarNeverFallsBackToUnsigned(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + good := buildBundle(t, bundleOpts{Seq: 150, KeyID: keyPublisher.id}) + + for name, sidecar := range map[string][]byte{ + "empty": nil, + "garbage": []byte("garbage"), + "empty obj": []byte("{}"), + "truncated": bundlesig.SignSidecar(keyPublisher.priv, good)[:40], + } { + t.Run(name, func(t *testing.T) { + acc, rej := f.eval(Candidate{Bundle: good, Sidecar: sidecar, SidecarPresent: true, Source: BundleSourceFile}) + assert.Nil(t, acc) + require.NotNil(t, rej) + assert.Equal(t, RejectSidecarInvalid, rej.Reason) + assert.NotEmpty(t, rej.Detail) + }) + } +} + +// --- T060: verify-before-parse, read-once, SC-001 tamper classes ------------- + +func TestEvaluateCandidate_TamperClasses_NeverCompileAnything(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + good := signedCandidate(t, keyPublisher, bundleOpts{Seq: 150, Tag: "bbb"}) + other := buildBundle(t, bundleOpts{Seq: 151, KeyID: keyPublisher.id}) + + forged := strings.Replace(string(signBundle(t, keyUntrusted.priv, good.Bundle)), keyUntrusted.id, keyPublisher.id, 1) + renamedManifest := signedCandidate(t, keyPublisher, bundleOpts{Seq: 150, KeyID: keyOperator.id}) + + cases := []struct { + name string + cand Candidate + reason string + }{ + {"flipped byte in the bundle", Candidate{Bundle: flipByte(t, good.Bundle, "bundle-marker"), Sidecar: good.Sidecar, SidecarPresent: true}, RejectTamper}, + {"signature over a different bundle", Candidate{Bundle: good.Bundle, Sidecar: signBundle(t, keyPublisher.priv, other), SidecarPresent: true}, RejectTamper}, + {"forged signature under a trusted key id", Candidate{Bundle: good.Bundle, Sidecar: []byte(forged), SidecarPresent: true}, RejectTamper}, + {"signature from an untrusted key", Candidate{Bundle: good.Bundle, Sidecar: signBundle(t, keyUntrusted.priv, good.Bundle), SidecarPresent: true}, RejectKeyNotTrusted}, + {"manifest names another key", renamedManifest, RejectTamper}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + n := countCompiles(t) + tc.cand.Source = BundleSourceFile + f.refuse(tc.cand, tc.reason) + assert.Zero(t, *n, "no pattern may be compiled for a candidate that failed verification") + }) + } + + t.Run("the untampered original compiles exactly its rules", func(t *testing.T) { + n := countCompiles(t) + _, rej := f.eval(good) + require.Nil(t, rej) + assert.Equal(t, 1, *n) + }) +} + +// FR-006: the bytes that were verified are the bytes that are parsed. A +// candidate carries its bytes; the path is only a label and is never re-read. +func TestEvaluateCandidate_ReadsOnce(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + c := signedCandidate(t, keyPublisher, bundleOpts{Seq: 150, Tag: "verified"}) + + // A different, perfectly valid, perfectly signed bundle sits at the path. + decoy := signedCandidate(t, keyPublisher, bundleOpts{Seq: 160, Tag: "decoy"}) + dir := t.TempDir() + c.Path = filepath.Join(dir, "scanner-bundle.json") + require.NoError(t, os.WriteFile(c.Path, decoy.Bundle, 0o600)) + require.NoError(t, os.WriteFile(c.Path+".sig", decoy.Sidecar, 0o600)) + + acc, rej := f.eval(c) + require.Nil(t, rej) + assert.Equal(t, digestOf(c.Bundle), acc.FullDigest) + assert.Equal(t, uint64(150), acc.Sequence, "the sequence is the VERIFIED buffer's, not the file's") + + hits := acc.Check.Inspect(detect.ToolView{Description: "bundle-marker-150-verified"}, detect.RegistryView{}) + assert.NotEmpty(t, hits, "the compiled rules are the verified buffer's") + none := acc.Check.Inspect(detect.ToolView{Description: "bundle-marker-160-decoy"}, detect.RegistryView{}) + assert.Empty(t, none, "the decoy file at the path was never read") +} + +// An accepted signed candidate reports everything the runtime publishes. +func TestEvaluateCandidate_AcceptedSignedFields(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + c := signedCandidate(t, keyPublisher, bundleOpts{Seq: 150, Epoch: 0, GeneratedAt: "2026-10-01T00:00:00Z"}) + + acc, rej := f.eval(c) + require.Nil(t, rej) + assert.True(t, acc.Verified) + assert.Equal(t, AuthorityPublisher, acc.Authority) + assert.Equal(t, uint64(150), acc.Sequence) + assert.Equal(t, uint64(0), acc.TrustEpoch) + assert.Equal(t, keyPublisher.id, acc.KeyID) + assert.Equal(t, digestOf(c.Bundle), acc.FullDigest) + assert.Equal(t, digestOf(c.Sidecar), acc.SidecarDigest) + assert.Equal(t, "0.1.0", acc.Meta.BundleVersion) + assert.Equal(t, "2026-10-01T00:00:00Z", acc.Meta.GeneratedAt) + assert.Equal(t, 1, acc.Stats.Runnable) + require.NotNil(t, acc.Check) +} + +func TestEvaluateCandidate_RejectionIsTimestamped(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + _, rej := f.eval(Candidate{Bundle: []byte("x"), Sidecar: []byte("{}"), SidecarPresent: true}) + require.NotNil(t, rej) + assert.True(t, rej.At.Equal(fixedNow), "the injected clock stamps the rejection") +} + +// --- T061: ceilings, both sides ------------------------------------------------ + +func TestEvaluateCandidate_BundleByteCeiling(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + + at := signedCandidate(t, keyPublisher, bundleOpts{Seq: 150, PadTo: MaxBundleBytes}) + require.Len(t, at.Bundle, MaxBundleBytes) + acc, rej := f.eval(at) + require.Nil(t, rej, "a bundle of exactly 8 MiB loads") + require.NotNil(t, acc) + + over := signedCandidate(t, keyPublisher, bundleOpts{Seq: 150, PadTo: MaxBundleBytes + 1}) + require.Len(t, over.Bundle, MaxBundleBytes+1) + rej2 := f.refuse(over, RejectCeilingExceeded) + assert.Contains(t, rej2.Detail, "bundle") + + uat := unsignedCandidate(t, bundleOpts{PadTo: MaxBundleBytes}) + _, rej = f.eval(uat) + assert.Nil(t, rej, "unsigned: exactly at the ceiling loads") + f.refuse(unsignedCandidate(t, bundleOpts{PadTo: MaxBundleBytes + 1}), RejectCeilingExceeded) +} + +func TestEvaluateCandidate_RuleCountCeiling(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + + at := signedCandidate(t, keyPublisher, bundleOpts{Seq: 150, Rules: MaxBundleRules}) + acc, rej := f.eval(at) + require.Nil(t, rej, "2000 rules load") + assert.Equal(t, MaxBundleRules, acc.Stats.Runnable) + + n := countCompiles(t) + over := signedCandidate(t, keyPublisher, bundleOpts{Seq: 150, Rules: MaxBundleRules + 1}) + rej2 := f.refuse(over, RejectCeilingExceeded) + assert.Equal(t, "rules: 2001 > 2000", rej2.Detail) + assert.Zero(t, *n, "the ceiling is enforced before anything is compiled") +} + +func TestEvaluateCandidate_SidecarByteCeiling(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + c := signedCandidate(t, keyPublisher, bundleOpts{Seq: 150}) + + pad := func(n int) []byte { + out := append([]byte(nil), c.Sidecar...) + return append(out, bytes.Repeat([]byte(" "), n-len(out))...) + } + + at := c + at.Sidecar = pad(MaxSidecarBytes) + require.Len(t, at.Sidecar, 4096) + _, rej := f.eval(at) + require.Nil(t, rej, "a 4096-byte sidecar loads") + + over := c + over.Sidecar = pad(MaxSidecarBytes + 1) + require.Len(t, over.Sidecar, 4097) + rej2 := f.refuse(over, RejectCeilingExceeded) + assert.Contains(t, rej2.Detail, "sidecar") +} + +// --- T062: manifest grammar ---------------------------------------------------- + +func TestParseSequence(t *testing.T) { + valid := map[string]uint64{ + `"1"`: 1, + `"9"`: 9, + `"10"`: 10, + `"18446744073709551615"`: 18446744073709551615, + } + for raw, want := range valid { + t.Run("accept "+raw, func(t *testing.T) { + got, s, err := parseSequence([]byte(raw)) + require.NoError(t, err) + assert.Equal(t, want, got) + assert.Equal(t, strings.Trim(raw, `"`), s) + }) + } + + invalid := map[string]string{ + "missing": ``, + "null": `null`, + "zero": `"0"`, + "leading zero": `"01"`, + "plus": `"+1"`, + "leading space": `" 1"`, + "trailing space": `"1 "`, + "exponent": `"1e3"`, + "empty": `""`, + "hex": `"0x10"`, + "negative": `"-1"`, + "fraction": `"1.0"`, + "bare number": `5`, + "bare big number": `18446744073709551615`, + "bool": `true`, + "object": `{}`, + "array": `["1"]`, + "21 digits": `"100000000000000000000"`, + "2^64": `"18446744073709551616"`, + "20 digits >2^64": `"99999999999999999999"`, + "unicode digit": `"١"`, + "escaped non-grammar": `"0"`, + } + for name, raw := range invalid { + t.Run("reject "+name, func(t *testing.T) { + _, _, err := parseSequence([]byte(raw)) + assert.Error(t, err) + }) + } +} + +func TestParseTrustEpoch(t *testing.T) { + valid := map[string]uint64{`0`: 0, `1`: 1, `42`: 42, `18446744073709551615`: 18446744073709551615} + for raw, want := range valid { + t.Run("accept "+raw, func(t *testing.T) { + got, err := parseTrustEpoch([]byte(raw)) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + } + for name, raw := range map[string]string{ + "missing": ``, + "null": `null`, + "negative": `-1`, + "fraction": `1.0`, + "exponent": `1e2`, + "string": `"1"`, + "plus": `+1`, + "2^64": `18446744073709551616`, + "bool": `false`, + "minus 0": `-0`, + "array": `[0]`, + "hex": `0x1`, + } { + t.Run("reject "+name, func(t *testing.T) { + _, err := parseTrustEpoch([]byte(raw)) + assert.Error(t, err) + }) + } +} + +func TestEvaluateCandidate_ManifestGrammar(t *testing.T) { + f := newPipelineFixture(t, 100, nil) + + for name, o := range map[string]bundleOpts{ + "bare-number sequence": {SeqRaw: "150"}, + "missing sequence": {}, + "sequence 0": {SeqRaw: `"0"`}, + "sequence leading zero": {SeqRaw: `"0150"`}, + "sequence 2^64": {SeqRaw: `"18446744073709551616"`}, + "missing trust_epoch": {Seq: 150, OmitEpoch: true}, + "fractional epoch": {Seq: 150, EpochRaw: "0.0"}, + "string epoch": {Seq: 150, EpochRaw: `"0"`}, + "negative epoch": {Seq: 150, EpochRaw: "-1"}, + } { + t.Run(name, func(t *testing.T) { + f.refuse(signedCandidate(t, keyPublisher, o), RejectInvalidBundle) + }) + } + + t.Run("max sequence is a valid sequence", func(t *testing.T) { + acc, rej := f.eval(signedCandidate(t, keyPublisher, bundleOpts{SeqRaw: `"18446744073709551615"`})) + require.Nil(t, rej) + assert.Equal(t, uint64(18446744073709551615), acc.Sequence) + }) + + t.Run("manifest key_id must be present and agree (row 13)", func(t *testing.T) { + missing := buildBundle(t, bundleOpts{Seq: 150}) + rej := f.refuse(Candidate{Bundle: missing, Sidecar: signBundle(t, keyPublisher.priv, missing), SidecarPresent: true}, RejectTamper) + assert.Contains(t, rej.Detail, bundlesig.CodeKeyIDMismatch) + + wrong := signedCandidate(t, keyPublisher, bundleOpts{Seq: 150, KeyID: keyUntrusted.id}) + rej = f.refuse(wrong, RejectTamper) + assert.Contains(t, rej.Detail, bundlesig.CodeKeyIDMismatch) + }) + + t.Run("unknown additive manifest keys are ignored", func(t *testing.T) { + _, rej := f.eval(signedCandidate(t, keyPublisher, bundleOpts{Seq: 150, PadTo: 4096})) + assert.Nil(t, rej) + }) +} + +// A rule-count error from the shared compile path is also what existing +// loadBundleCheck callers see. +func TestLoadBundleCheck_RuleCeilingForExistingCallers(t *testing.T) { + n := countCompiles(t) + _, _, err := loadBundleCheck(buildBundle(t, bundleOpts{Rules: MaxBundleRules + 1})) + require.Error(t, err) + var ce *bundleCeilingError + assert.True(t, errors.As(err, &ce)) + assert.Zero(t, *n) + + _, stats, err := loadBundleCheck(buildBundle(t, bundleOpts{Rules: MaxBundleRules})) + require.NoError(t, err) + assert.Equal(t, MaxBundleRules, stats.Runnable) +} + +// SC-010 guard: the collision template really produces two bundles sharing a +// 12-hex fingerprint and nothing else. If the template drifts this fails first, +// before any rollback test silently loses its meaning. +func TestSC010CollisionFixture(t *testing.T) { + a, b := sc010Bundle(sc010NonceA), sc010Bundle(sc010NonceB) + assert.Equal(t, sc010DigestA, digestOf(a)) + assert.Equal(t, sc010DigestB, digestOf(b)) + assert.Equal(t, sc010Fingerprint, digestOf(a)[:12]) + assert.Equal(t, sc010Fingerprint, digestOf(b)[:12]) + assert.NotEqual(t, a, b) + assert.Equal(t, keyPublisher.id, sc010KeyID, "the template names the publisher test key") + + // Both are genuinely valid publisher-signed bundles at sequence 5. + f := newPipelineFixture(t, 0, nil) + for _, raw := range [][]byte{a, b} { + acc, rej := f.eval(Candidate{Bundle: raw, Sidecar: signBundle(t, keyPublisher.priv, raw), SidecarPresent: true, Source: BundleSourceFile}) + require.Nil(t, rej) + assert.Equal(t, uint64(5), acc.Sequence) + } +} diff --git a/internal/security/scanner/tpa_helpers_test.go b/internal/security/scanner/tpa_helpers_test.go new file mode 100644 index 000000000..96f556c03 --- /dev/null +++ b/internal/security/scanner/tpa_helpers_test.go @@ -0,0 +1,313 @@ +package scanner + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/security/bundlesig" +) + +// Shared Spec 101 test helpers. The Wave 2 runtime tests (tpa_bundle_runtime*, +// rollback, crash points) build on exactly these, so keys are DETERMINISTIC: +// the same seeds always yield the same key ids, which is what lets the SC-010 +// collision fixture below name its signing key in advance. + +// testKey is one deterministic Ed25519 identity. +type testKey struct { + priv ed25519.PrivateKey + pub ed25519.PublicKey + // id is bundlesig.KeyID(pub): the lowercase-hex SHA-256 of the public key. + id string + // b64 is the standard padded base64 of the raw public key, the form + // publisherSigners and security.tpa_operator_signers carry. + b64 string +} + +func newTestKey(seedByte byte) testKey { + priv := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{seedByte}, ed25519.SeedSize)) + pub := priv.Public().(ed25519.PublicKey) + return testKey{priv: priv, pub: pub, id: bundlesig.KeyID(pub), b64: base64.StdEncoding.EncodeToString(pub)} +} + +var ( + // keyPublisher is the publisher test key (seed 0x11). + keyPublisher = newTestKey(0x11) + // keyOperator is the operator test key (seed 0x22). + keyOperator = newTestKey(0x22) + // keyUntrusted is a key that is in NO trust set (seed 0x33). + keyUntrusted = newTestKey(0x33) +) + +// withTrustAnchors installs publisher trust anchors for one test: the publisher +// signer set, the publisher epoch and (when embedded is non-nil) the embedded +// default bundle. Everything is restored on cleanup. +func withTrustAnchors(t *testing.T, publisherKeys []ed25519.PublicKey, publisherEpoch uint64, embedded []byte) { + t.Helper() + prevSigners, prevEpoch, prevBase, prevEmbedded := publisherSigners, publisherTrustEpoch, publisherFallbackBaseline, bundledScannerBundle + t.Cleanup(func() { + publisherSigners, publisherTrustEpoch, publisherFallbackBaseline, bundledScannerBundle = prevSigners, prevEpoch, prevBase, prevEmbedded + }) + signers := make([]string, 0, len(publisherKeys)) + for _, k := range publisherKeys { + signers = append(signers, base64.StdEncoding.EncodeToString(k)) + } + publisherSigners = signers + publisherTrustEpoch = publisherEpoch + if embedded != nil { + bundledScannerBundle = embedded + } +} + +// bundleOpts renders one minimal valid manifest. Zero values mean "a normal +// signed-release manifest"; the Raw/Omit/Bad fields break exactly one thing. +type bundleOpts struct { + // Seq is the manifest sequence rendered as a decimal STRING; 0 omits it. + Seq uint64 + // SeqRaw, when non-empty, is the raw JSON value of "sequence" (e.g. a bare + // number) and overrides Seq. + SeqRaw string + // Epoch is the manifest trust_epoch; OmitEpoch drops the key and EpochRaw + // overrides the raw JSON value. + Epoch uint64 + OmitEpoch bool + EpochRaw string + // KeyID is the manifest key_id; "" omits it. + KeyID string + // Rules is the number of regex rules (default 1; negative means none). + Rules int + // PadTo pads the rendered bundle to exactly this many bytes. + PadTo int + // Version overrides bundle_version (default "0.1.0"). + Version string + // Tag makes two bundles with the same Seq differ byte-for-byte. + Tag string + // GeneratedAt is rendered as generated_at when non-empty. + GeneratedAt string + // BadPattern makes the first rule an uncompilable regex; NoRunnable makes + // every rule non-runnable offline. + BadPattern bool + NoRunnable bool +} + +// buildBundle renders a minimal valid manifest (one line, keys sorted). +func buildBundle(t *testing.T, o bundleOpts) []byte { + t.Helper() + version := o.Version + if version == "" { + version = "0.1.0" + } + n := o.Rules + if n == 0 { + n = 1 + } + if n < 0 { + n = 0 + } + var rules []string + for i := 0; i < n; i++ { + pattern := fmt.Sprintf("never-matches-%04d", i) + if i == 0 { + pattern = fmt.Sprintf("bundle-marker-%d-%s", o.Seq, o.Tag) + if o.BadPattern { + pattern = "(unclosed" + } + } + engine := "regex" + if o.NoRunnable { + engine = "structural_diff" + } + rules = append(rules, fmt.Sprintf( + `{"category":"rug-pull","confidence":0.9,"detector":"d%d","engine":%q,"id":"TPA-2099-%04d","level":"high","pattern":%q,"target":"tool_description"}`, + i, engine, i, pattern)) + } + + var sb strings.Builder + sb.WriteString(`{"bundle_version":` + fmt.Sprintf("%q", version)) + if o.GeneratedAt != "" { + sb.WriteString(`,"generated_at":` + fmt.Sprintf("%q", o.GeneratedAt)) + } + if o.KeyID != "" { + sb.WriteString(`,"key_id":` + fmt.Sprintf("%q", o.KeyID)) + } + sb.WriteString(`,"rules":[` + strings.Join(rules, ",") + `]`) + sb.WriteString(`,"schema_version":"0.1.0"`) + switch { + case o.SeqRaw != "": + sb.WriteString(`,"sequence":` + o.SeqRaw) + case o.Seq > 0: + fmt.Fprintf(&sb, `,"sequence":"%d"`, o.Seq) + } + fmt.Fprintf(&sb, `,"signature_count":%d`, n) + switch { + case o.EpochRaw != "": + sb.WriteString(`,"trust_epoch":` + o.EpochRaw) + case !o.OmitEpoch: + fmt.Fprintf(&sb, `,"trust_epoch":%d`, o.Epoch) + } + sb.WriteString("}") + out := []byte(sb.String()) + + if o.PadTo > 0 { + const overhead = len(`,"padding":""`) + need := o.PadTo - len(out) - overhead + require.GreaterOrEqual(t, need, 0, "PadTo %d is smaller than the bundle itself (%d)", o.PadTo, len(out)+overhead) + padded := make([]byte, 0, o.PadTo) + padded = append(padded, out[:len(out)-1]...) + padded = append(padded, `,"padding":"`...) + padded = append(padded, bytes.Repeat([]byte{'x'}, need)...) + padded = append(padded, `"}`...) + out = padded + require.Len(t, out, o.PadTo) + } + return out +} + +// signBundle returns the canonical sidecar for bundle under priv. +func signBundle(t *testing.T, priv ed25519.PrivateKey, bundle []byte) []byte { + t.Helper() + return bundlesig.SignSidecar(priv, bundle) +} + +// signedCandidate builds a manifest whose key_id names key (unless the options +// say otherwise), signs it with key, and wraps both in a file-sourced Candidate. +func signedCandidate(t *testing.T, key testKey, o bundleOpts) Candidate { + t.Helper() + if o.KeyID == "" { + o.KeyID = key.id + } + b := buildBundle(t, o) + return Candidate{Bundle: b, Sidecar: signBundle(t, key.priv, b), SidecarPresent: true, Source: BundleSourceFile, Path: "/drop/scanner-bundle.json"} +} + +// unsignedCandidate builds an unsigned file-sourced Candidate. +func unsignedCandidate(t *testing.T, o bundleOpts) Candidate { + t.Helper() + return Candidate{Bundle: buildBundle(t, o), Source: BundleSourceFile, Path: "/drop/scanner-bundle.json"} +} + +// digestOf is the full lowercase-hex SHA-256 of b. +func digestOf(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +func u64p(v uint64) *uint64 { return &v } + +// --- SC-010: a REAL 48-bit fingerprint collision (birthday search, precomputed) --- + +const ( + // sc010Template is the collision template: one line, no trailing newline. + // KEYID and NONCE are substituted by sc010Bundle. + sc010Template = `{"bundle_version":"0.1.0","generated_from":"sc010-collision-NONCE","key_id":"KEYID","rules":[{"category":"rug-pull","confidence":0.9,"detector":"sc010","engine":"regex","id":"TPA-2026-9010","level":"high","pattern":"(?i)sc010-collision-marker","target":"tool_description"}],"schema_version":"0.1.0","sequence":"5","signature_count":1,"trust_epoch":0}` + // sc010KeyID is the key id of ed25519.NewKeyFromSeed(bytes.Repeat(0x11, 32)), + // i.e. keyPublisher. + sc010KeyID = "10ba682c8ad13513971e8b56881aab8bd702bb807796eca81932c735a94d6e6d" + // sc010NonceA / sc010NonceB produce bundles A and B, which share their first + // 12 hex digest characters and nothing else. + sc010NonceA = "0000000e5e41" + sc010NonceB = "00000145dd8d" + sc010DigestA = "6cd669dbf40882df872bf7b7b29ca1596671b915992e35d957d6a6ff62c37db3" + sc010DigestB = "6cd669dbf408404bc7280e8d622dc6b4f8132785980ee32b7c7baf6119112354" + // sc010Fingerprint is the shared 12-hex human fingerprint. + sc010Fingerprint = "6cd669dbf408" +) + +// sc010Bundle renders collision bundle A or B (pass sc010NonceA / sc010NonceB). +func sc010Bundle(nonce string) []byte { + return []byte(strings.NewReplacer("NONCE", nonce, "KEYID", sc010KeyID).Replace(sc010Template)) +} + +// --- policy / environment fixtures --- + +// testPolicy builds a policy from a SecurityConfig seeded with the operator test +// key; mutate may change it. The path is always "" so MCPPROXY_TPA_BUNDLE_PATH +// can never leak into a test. +func testPolicy(mutate func(*config.SecurityConfig)) *bundlePolicy { + sec := &config.SecurityConfig{TPAOperatorSigners: []string{keyOperator.b64}} + if mutate != nil { + mutate(sec) + } + return policyFor(sec, "") +} + +// fixedNow is the clock every pure test uses. +var fixedNow = time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC) + +// pipelineFixture is the pure analogue of one runtime: a policy and the +// committed state, advanced only through applyActivation / applyRollback. +type pipelineFixture struct { + t *testing.T + policy *bundlePolicy + st *TPABundleState +} + +// newPipelineFixture installs the publisher test key as the only publisher +// signer at publisher epoch 0, an embedded default carrying embeddedSeq (0 = +// none), and a fresh (nil) state. +func newPipelineFixture(t *testing.T, embeddedSeq uint64, mutate func(*config.SecurityConfig)) *pipelineFixture { + t.Helper() + withTrustAnchors(t, []ed25519.PublicKey{keyPublisher.pub}, 0, + buildBundle(t, bundleOpts{Seq: embeddedSeq, Tag: "embedded"})) + f := &pipelineFixture{t: t, policy: testPolicy(mutate)} + f.renormalize() + return f +} + +// renormalize re-derives the state the way every pipeline run does. +func (f *pipelineFixture) renormalize() { + f.st, _ = normalizeState(f.st, f.policy) +} + +// repolicy swaps the policy (a config change) and renormalizes. +func (f *pipelineFixture) repolicy(mutate func(*config.SecurityConfig)) { + f.policy = testPolicy(mutate) + f.renormalize() +} + +func (f *pipelineFixture) env(mode evalMode) evalEnv { + return evalEnv{Policy: f.policy, State: f.st, Mode: mode, Now: func() time.Time { return fixedNow }} +} + +// eval evaluates c in normal mode. +func (f *pipelineFixture) eval(c Candidate) (*acceptedBundle, *BundleRejection) { + return evaluateCandidate(c, f.env(evalNormal)) +} + +// activate evaluates c, requires acceptance, applies the activation and +// returns the accepted bundle. +func (f *pipelineFixture) activate(c Candidate) *acceptedBundle { + f.t.Helper() + acc, rej := f.eval(c) + require.Nil(f.t, rej, "candidate must be accepted") + require.NotNil(f.t, acc) + f.st, _ = applyActivation(f.st, acc, c, fixedNow) + f.renormalize() + return acc +} + +// refuse evaluates c and requires rejection with the given reason. +func (f *pipelineFixture) refuse(c Candidate, reason string) *BundleRejection { + f.t.Helper() + acc, rej := f.eval(c) + require.Nil(f.t, acc, "candidate must be refused") + require.NotNil(f.t, rej) + require.Equal(f.t, reason, rej.Reason, "detail: %s", rej.Detail) + return rej +} + +func (f *pipelineFixture) auth(name string) *TPAAuthorityState { + f.t.Helper() + a := f.st.Authorities[name] + require.NotNil(f.t, a, "authority %s state", name) + return a +} diff --git a/internal/security/scanner/tpa_policy_test.go b/internal/security/scanner/tpa_policy_test.go new file mode 100644 index 000000000..e5ce464f9 --- /dev/null +++ b/internal/security/scanner/tpa_policy_test.go @@ -0,0 +1,194 @@ +package scanner + +import ( + "crypto/ed25519" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +func TestBuildTrustSet_UnionOfAuthorities(t *testing.T) { + ts, warnings := buildTrustSet([]string{keyPublisher.b64}, []string{keyOperator.b64}) + require.Empty(t, warnings) + + pk, ok := ts.Lookup(keyPublisher.id) + require.True(t, ok) + assert.Equal(t, AuthorityPublisher, pk.Authority) + + ok2, found := ts.Lookup(keyOperator.id) + require.True(t, found) + assert.Equal(t, AuthorityOperator, ok2.Authority) + + _, found = ts.Lookup(keyUntrusted.id) + assert.False(t, found) +} + +func TestBuildTrustSet_DropsBadOperatorSignersWithoutEchoingThem(t *testing.T) { + const garbage = "not-base64-@@@-SECRETISH" + short := "AAAA" // valid base64, 3 bytes + ts, warnings := buildTrustSet(nil, []string{garbage, short, keyOperator.b64, keyOperator.b64}) + + _, ok := ts.Lookup(keyOperator.id) + require.True(t, ok, "the one valid operator key is kept") + require.Len(t, warnings, 3, "garbage, short and the duplicate each produce one warning") + for _, w := range warnings { + assert.NotContains(t, w, garbage, "a warning must not echo the offending value") + assert.NotContains(t, w, short) + } +} + +func TestBuildTrustSet_KeyInBothSetsKeepsThePublisherCopy(t *testing.T) { + ts, warnings := buildTrustSet([]string{keyPublisher.b64}, []string{keyPublisher.b64, keyOperator.b64}) + require.Len(t, warnings, 1) + + k, ok := ts.Lookup(keyPublisher.id) + require.True(t, ok) + assert.Equal(t, AuthorityPublisher, k.Authority, "a key can never satisfy the operator root as well") + + op, ok := ts.Lookup(keyOperator.id) + require.True(t, ok) + assert.Equal(t, AuthorityOperator, op.Authority) +} + +func TestBuildTrustSet_DropsMalformedAndDuplicatePublisherSigners(t *testing.T) { + ts, warnings := buildTrustSet([]string{"@@@", keyPublisher.b64, keyPublisher.b64}, nil) + require.Len(t, warnings, 2) + _, ok := ts.Lookup(keyPublisher.id) + assert.True(t, ok) +} + +func TestPolicyFromConfig_Defaults(t *testing.T) { + withTrustAnchors(t, []ed25519.PublicKey{keyPublisher.pub}, 3, buildBundle(t, bundleOpts{Seq: 42})) + p := policyFor(&config.SecurityConfig{}, "") + + assert.False(t, p.RequireSigned) + assert.Equal(t, AuthorityPublisher, p.ActiveRoot) + assert.False(t, p.OperatorBaselineSet) + assert.Equal(t, uint64(0), p.OperatorEpoch) + assert.Equal(t, uint64(3), p.currentEpoch(AuthorityPublisher)) + assert.Equal(t, uint64(0), p.currentEpoch(AuthorityOperator)) + assert.Equal(t, uint64(0), p.currentEpoch("nobody")) + require.NotNil(t, p.Trust) + _, ok := p.Trust.Lookup(keyPublisher.id) + assert.True(t, ok) + assert.Len(t, p.Fingerprint, 64) +} + +func TestPolicy_Baselines(t *testing.T) { + t.Run("publisher baseline is the embedded sequence", func(t *testing.T) { + withTrustAnchors(t, nil, 0, buildBundle(t, bundleOpts{Seq: 42})) + p := policyFor(&config.SecurityConfig{}, "") + b, ok := p.baseline(AuthorityPublisher) + assert.True(t, ok) + assert.Equal(t, uint64(42), b) + seq, has := embeddedSequence() + assert.True(t, has) + assert.Equal(t, uint64(42), seq) + }) + + t.Run("publisher baseline falls back when the embedded manifest has no sequence", func(t *testing.T) { + withTrustAnchors(t, nil, 0, buildBundle(t, bundleOpts{})) + publisherFallbackBaseline = 7 + p := policyFor(&config.SecurityConfig{}, "") + b, ok := p.baseline(AuthorityPublisher) + assert.True(t, ok, "the publisher always has a baseline") + assert.Equal(t, uint64(7), b) + _, has := embeddedSequence() + assert.False(t, has) + }) + + t.Run("operator baseline only when configured", func(t *testing.T) { + withTrustAnchors(t, nil, 0, nil) + p := policyFor(&config.SecurityConfig{}, "") + _, ok := p.baseline(AuthorityOperator) + assert.False(t, ok, "no silent zero") + + p = policyFor(&config.SecurityConfig{TPAOperatorBaseline: u64p(0)}, "") + b, ok := p.baseline(AuthorityOperator) + assert.True(t, ok, "an explicit 0 is a declared baseline") + assert.Equal(t, uint64(0), b) + + p = policyFor(&config.SecurityConfig{TPAOperatorBaseline: u64p(9), TPAOperatorEpoch: u64p(4)}, "") + b, _ = p.baseline(AuthorityOperator) + assert.Equal(t, uint64(9), b) + assert.Equal(t, uint64(4), p.currentEpoch(AuthorityOperator)) + }) +} + +func TestPolicyFromConfig_PathEnvOverrideOnlyOnTheConfigEntryPoint(t *testing.T) { + withTrustAnchors(t, nil, 0, nil) + t.Setenv(config.EnvTPABundlePath, "/env/bundle.json") + sec := &config.SecurityConfig{TPABundlePath: "/file/bundle.json"} + + assert.Equal(t, "/env/bundle.json", policyFromConfig(sec).Path, "the env override wins on the config entry point") + assert.Equal(t, "/explicit.json", policyFor(sec, "/explicit.json").Path, "an explicit path is taken verbatim") +} + +func TestPolicyFingerprint(t *testing.T) { + withTrustAnchors(t, []ed25519.PublicKey{keyPublisher.pub}, 0, buildBundle(t, bundleOpts{Seq: 5})) + base := func() *config.SecurityConfig { + return &config.SecurityConfig{TPAOperatorSigners: []string{keyOperator.b64}} + } + fp := func(mut func(*config.SecurityConfig)) string { + sec := base() + if mut != nil { + mut(sec) + } + return policyFor(sec, "/p").Fingerprint + } + + ref := fp(nil) + assert.Equal(t, ref, fp(nil), "deterministic") + + differs := map[string]func(*config.SecurityConfig){ + "require signed": func(s *config.SecurityConfig) { s.RequireSignedBundle = true }, + "active root": func(s *config.SecurityConfig) { s.TPAActiveRoot = config.TPARootOperator }, + "operator signers": func(s *config.SecurityConfig) { s.TPAOperatorSigners = nil }, + "operator baseline 0": func(s *config.SecurityConfig) { s.TPAOperatorBaseline = u64p(0) }, + "operator baseline 1": func(s *config.SecurityConfig) { s.TPAOperatorBaseline = u64p(1) }, + "operator epoch": func(s *config.SecurityConfig) { s.TPAOperatorEpoch = u64p(2) }, + "extra operator signer": func(s *config.SecurityConfig) { s.TPAOperatorSigners = append(s.TPAOperatorSigners, keyUntrusted.b64) }, + } + seen := map[string]string{"ref": ref} + for name, mut := range differs { + got := fp(mut) + assert.NotEqual(t, ref, got, name) + for prev, v := range seen { + assert.NotEqual(t, v, got, "%s collides with %s", name, prev) + } + seen[name] = got + } + assert.NotEqual(t, policyFor(base(), "/p").Fingerprint, policyFor(base(), "/q").Fingerprint, "the path is part of the key") + + // Key order is not significant. + a := fp(func(s *config.SecurityConfig) { s.TPAOperatorSigners = []string{keyOperator.b64, keyUntrusted.b64} }) + b := fp(func(s *config.SecurityConfig) { s.TPAOperatorSigners = []string{keyUntrusted.b64, keyOperator.b64} }) + assert.Equal(t, a, b) + + // The binary's own anchors are part of the key as well. + withTrustAnchors(t, []ed25519.PublicKey{keyPublisher.pub}, 1, buildBundle(t, bundleOpts{Seq: 5})) + assert.NotEqual(t, ref, fp(nil), "publisher epoch") + withTrustAnchors(t, []ed25519.PublicKey{keyPublisher.pub}, 0, buildBundle(t, bundleOpts{Seq: 6})) + assert.NotEqual(t, ref, fp(nil), "publisher baseline") +} + +func TestPolicy_ActiveRootIsKeptVerbatim(t *testing.T) { + p := policyFor(&config.SecurityConfig{TPAActiveRoot: "bogus"}, "") + assert.Equal(t, "bogus", p.ActiveRoot) +} + +// The trust set built for a policy must hand bundlesig keys of the right +// authority, so Verify's result can be compared against ActiveRoot. +func TestPolicy_TrustSetAuthorities(t *testing.T) { + withTrustAnchors(t, []ed25519.PublicKey{keyPublisher.pub}, 0, nil) + p := testPolicy(nil) + + k, ok := p.Trust.Lookup(keyOperator.id) + require.True(t, ok) + assert.Equal(t, AuthorityOperator, k.Authority) + assert.False(t, strings.Contains(strings.Join(p.Warnings, " "), keyOperator.b64)) +} diff --git a/internal/security/scanner/tpa_trust_anchors.go b/internal/security/scanner/tpa_trust_anchors.go new file mode 100644 index 000000000..16e0deda0 --- /dev/null +++ b/internal/security/scanner/tpa_trust_anchors.go @@ -0,0 +1,84 @@ +package scanner + +import ( + "fmt" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/security/bundlesig" +) + +// Publisher trust anchors (spec 101 FR-004, FR-005, FR-005a; tasks.md A1). +// +// These are package variables, not constants, only so a test can swap them +// through withTrustAnchors; production code never assigns them. + +// publisherSigners is the embedded publisher signer set: standard padded +// base64 of each raw 32-byte Ed25519 public key. It is EMPTY today because no +// publisher key has been issued yet, so every publisher-signed candidate is +// refused key_not_trusted and the operator authority is the only signed path. +// +// A release that adds a publisher key MUST also stamp the embedded +// bundled/scanner-bundle.json with a `sequence` (that sequence is the publisher +// baseline, FR-009b) and, when it revokes a compromised key, bump +// publisherTrustEpoch. Key removal and epoch bumps are binary-release events +// only (FR-005, FR-005a). +var publisherSigners = []string{} + +// publisherTrustEpoch is the publisher authority's current trust epoch +// (FR-005a). It advances only through a binary release. +var publisherTrustEpoch uint64 + +// publisherFallbackBaseline seeds the publisher watermark only when the +// embedded manifest carries no `sequence` (FR-009b). +var publisherFallbackBaseline uint64 + +// buildTrustSet builds the verification key set: every publisher signer +// (authority publisher) plus every valid operator signer (authority operator). +// +// An invalid or duplicate signer is DROPPED with a warning rather than +// failing the build: a bad operator key must never brick boot (config.Load +// drops them too) and must never widen what is trusted. A key listed in both +// sets keeps its publisher entry and loses its operator copy, so one key can +// never satisfy whichever root happens to be active. Warnings never echo the +// offending value. +func buildTrustSet(publisher, operator []string) (*bundlesig.TrustSet, []string) { + ts, _, warnings := buildTrustKeys(publisher, operator) + return ts, warnings +} + +// buildTrustKeys is buildTrustSet that also returns the accepted keys, which +// the policy fingerprint needs (a TrustSet is deliberately not enumerable). +func buildTrustKeys(publisher, operator []string) (*bundlesig.TrustSet, []bundlesig.Key, []string) { + var ( + warnings []string + keys []bundlesig.Key + seen = make(map[string]struct{}, len(publisher)+len(operator)) + ) + add := func(set string, signers []string, authority string) { + for i, s := range signers { + pub, err := bundlesig.ParsePublicKey(s) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s signer %d dropped: not a valid Ed25519 public key (%v)", set, i, err)) + continue + } + id := bundlesig.KeyID(pub) + if _, dup := seen[id]; dup { + warnings = append(warnings, fmt.Sprintf("%s signer %d dropped: key %s is already trusted", set, i, id)) + continue + } + seen[id] = struct{}{} + keys = append(keys, bundlesig.Key{ID: id, Public: pub, Authority: authority}) + } + } + // Publisher first, so a key listed in both sets keeps the publisher copy. + add("publisher", publisher, AuthorityPublisher) + add("operator", operator, AuthorityOperator) + + ts, err := bundlesig.NewTrustSet(keys...) + if err != nil { + // Unreachable: every key above is length-checked, fingerprint-derived + // and de-duplicated. Fail closed to an empty set rather than guess. + empty, _ := bundlesig.NewTrustSet() + return empty, nil, append(warnings, "trust set could not be built; no key is trusted: "+err.Error()) + } + return ts, keys, warnings +} From 5cf861647956eb546a1beba4c567875c1877d2d0 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 13:57:51 +0300 Subject: [PATCH 10/35] feat(security): Spec 101 US1 serialized bundle runtime, LKG store, rollback and coverage gating Adds the runtime layer over the pure candidate pipeline: SetBundleRuntime, ConfigureBundleFromConfig, ReloadBundle and an idempotent, mutex-serialized run (read once, evaluate, choose what serves, write the content-addressed last-known-good files, commit state plus history row in one transaction, publish). Rollback by full digest, unpin, activation history, StalenessDays, state_commit_failed (a candidate that cannot be made durable never activates), and coverageOK now follows the published coverage verdict taken from the same snapshot as the check. Related #938 --- internal/security/scanner/inprocess.go | 15 +- .../scanner/inprocess_coverage_test.go | 137 +++ internal/security/scanner/service.go | 26 +- internal/security/scanner/tpa_bundle.go | 26 +- .../security/scanner/tpa_bundle_crash_test.go | 238 ++++++ .../scanner/tpa_bundle_fifo_unix_test.go | 51 ++ internal/security/scanner/tpa_bundle_lkg.go | 240 ++++++ .../security/scanner/tpa_bundle_rollback.go | 238 ++++++ .../scanner/tpa_bundle_rollback_test.go | 330 +++++++ .../security/scanner/tpa_bundle_runtime.go | 808 ++++++++++++++++++ .../scanner/tpa_bundle_runtime_test.go | 625 ++++++++++++++ .../security/scanner/tpa_bundle_source.go | 184 ++-- .../scanner/tpa_bundle_source_test.go | 4 +- .../scanner/tpa_bundle_substitution_test.go | 84 ++ internal/security/scanner/tpa_candidate.go | 11 + .../scanner/tpa_runtime_helpers_test.go | 293 +++++++ 16 files changed, 3172 insertions(+), 138 deletions(-) create mode 100644 internal/security/scanner/inprocess_coverage_test.go create mode 100644 internal/security/scanner/tpa_bundle_crash_test.go create mode 100644 internal/security/scanner/tpa_bundle_fifo_unix_test.go create mode 100644 internal/security/scanner/tpa_bundle_lkg.go create mode 100644 internal/security/scanner/tpa_bundle_rollback.go create mode 100644 internal/security/scanner/tpa_bundle_rollback_test.go create mode 100644 internal/security/scanner/tpa_bundle_runtime.go create mode 100644 internal/security/scanner/tpa_bundle_runtime_test.go create mode 100644 internal/security/scanner/tpa_bundle_substitution_test.go create mode 100644 internal/security/scanner/tpa_runtime_helpers_test.go diff --git a/internal/security/scanner/inprocess.go b/internal/security/scanner/inprocess.go index 7a2187435..88173c3c1 100644 --- a/internal/security/scanner/inprocess.go +++ b/internal/security/scanner/inprocess.go @@ -105,7 +105,12 @@ func ScanToolMetadataVerdict(serverName string, tools []*config.ToolMetadata, pe engineChecks := baselineEngineChecks() bundlePresent := false - if bundleCheck := defaultBundleCheck(); bundleCheck != nil { + // ONE snapshot: the check that scans and the coverage verdict that gates + // auto-approval must describe the same corpus, so they are never taken from + // two lock acquisitions (a reload in between could pair a fresh check with + // a stale coverage flag). + bundleCheck, bundleInfo := activeBundleSnapshot() + if bundleCheck != nil { engineChecks = append(engineChecks, bundleCheck) bundlePresent = true } @@ -127,8 +132,12 @@ func ScanToolMetadataVerdict(serverName string, tools []*config.ToolMetadata, pe verdict, _ = deriveBaselineVerdict(out) // Fail closed on degraded coverage or a missing bundle: only a fully-covered, - // bundle-backed scan may yield an auto-approvable verdict (FR-014). - coverageOK = bundlePresent && result.Coverage.ChecksFailed == 0 + // bundle-backed scan may yield an auto-approvable verdict (FR-014). A bundle + // that is present but demoted (unsigned external, embedded below the + // retained watermark, a lost operator bundle, an unbaselined operator + // authority: spec 101 FR-007/FR-009a/FR-009b) is NOT full coverage either -- + // the verdict itself is unchanged, the caller just must not auto-approve it. + coverageOK = bundlePresent && !bundleInfo.CoverageDegraded && result.Coverage.ChecksFailed == 0 return verdict, out, coverageOK } diff --git a/internal/security/scanner/inprocess_coverage_test.go b/internal/security/scanner/inprocess_coverage_test.go new file mode 100644 index 000000000..32f5451ae --- /dev/null +++ b/internal/security/scanner/inprocess_coverage_test.go @@ -0,0 +1,137 @@ +package scanner + +import ( + "crypto/ed25519" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// benignTools is a toolset no rule of any test bundle matches, so the verdict is +// "clean" and the only thing that can vary is coverage. +func benignTools() []*config.ToolMetadata { + return []*config.ToolMetadata{{ + ServerName: "srv", + Name: "list_instances", + Description: "List Cloud SQL instances in the given project. Returns instance names and regions.", + ParamsJSON: `{"type":"object"}`, + }} +} + +// requireCoverage asserts what the scan gate sees: the bundle-coverage flag from +// BundleCoverageDegraded and coverageOK from ScanToolMetadataVerdict, which +// must be the exact inverse for a clean toolset. +func requireCoverage(t *testing.T, degraded bool, reason string) { + t.Helper() + gotDegraded, gotReason := BundleCoverageDegraded() + assert.Equal(t, degraded, gotDegraded) + assert.Equal(t, reason, gotReason) + info := BundleStatus() + assert.Equal(t, degraded, info.CoverageDegraded) + assert.Equal(t, reason, info.DegradedReason) + + verdict, findings, coverageOK := ScanToolMetadataVerdict("srv", benignTools(), nil) + assert.Equal(t, "clean", verdict, "a degraded bundle never changes the verdict itself; findings=%+v", findings) + assert.Equal(t, !degraded, coverageOK, "coverageOK must follow the degraded state") +} + +// The four conditions of FR-007 / FR-009a / FR-009b, and only those, degrade. +func TestCoverage_DegradingConditions(t *testing.T) { + t.Run("unsigned external bundle active", func(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + f.put(unsignedCandidate(t, bundleOpts{Tag: "unsigned"})) + f.run() + requireCoverage(t, true, DegradedUnsignedExternalActive) + }) + + t.Run("operator bundle under an unbaselined operator authority", func(t *testing.T) { + f := newRTFixture(t, rtOpts{Sec: func(s *config.SecurityConfig) { s.TPAActiveRoot = config.TPARootOperator }}) + f.activate(signedCandidate(t, keyOperator, bundleOpts{Seq: 5})) + requireCoverage(t, true, DegradedOperatorAuthorityUnbaselined) + }) + + t.Run("embedded default below the publisher watermark", func(t *testing.T) { + f := newRTFixture(t, rtOpts{EmbeddedSeq: 3}) + f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 10})) + requireCoverage(t, false, "") + f.sec.TPABundlePath = "" + f.run() + requireCoverage(t, true, DegradedEmbeddedBelowWatermark) + }) + + t.Run("operator root active and its bundle gone", func(t *testing.T) { + f := newRTFixture(t, rtOpts{Sec: func(s *config.SecurityConfig) { + s.TPAActiveRoot = config.TPARootOperator + s.TPAOperatorBaseline = u64p(1) + }}) + f.activate(signedCandidate(t, keyOperator, bundleOpts{Seq: 5})) + requireCoverage(t, false, "") + f.sec.TPABundlePath = "" + f.run() + requireCoverage(t, true, DegradedOperatorBundleMissing) + }) +} + +func TestCoverage_MustNotDegrade(t *testing.T) { + t.Run("fresh install on the embedded default", func(t *testing.T) { + f := newRTFixture(t, rtOpts{EmbeddedSeq: 100}) + f.sec.TPABundlePath = "" + f.run() + requireCoverage(t, false, "") + assert.Equal(t, BundleSourceEmbedded, BundleStatus().Source) + }) + + t.Run("embedded at or above the publisher watermark after a binary upgrade", func(t *testing.T) { + f := newRTFixture(t, rtOpts{EmbeddedSeq: 100}) + f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 120})) + + // A binary upgrade ships a newer embedded corpus (sequence 150). The + // publisher baseline re-applies on start, so the old external release is + // now strictly worse than the fallback: it is refused, the embedded + // default serves, and it sits ABOVE the retained watermark, so it is + // full coverage rather than degraded. + withTrustAnchors(t, []ed25519.PublicKey{keyPublisher.pub}, 0, buildBundle(t, bundleOpts{Seq: 150, Tag: "newer-binary"})) + f.restart() + info := f.run() + assert.Equal(t, BundleSourceEmbedded, info.Source) + requireCoverage(t, false, "") + }) + + t.Run("operator root declared but nothing ever activated", func(t *testing.T) { + f := newRTFixture(t, rtOpts{Sec: func(s *config.SecurityConfig) { s.TPAActiveRoot = config.TPARootOperator }}) + f.sec.TPABundlePath = "" + f.run() + requireCoverage(t, false, "") + }) + + t.Run("operator watermark 900 against an embedded publisher corpus at 100", func(t *testing.T) { + f := newRTFixture(t, rtOpts{EmbeddedSeq: 100, Sec: func(s *config.SecurityConfig) { + s.TPAActiveRoot = config.TPARootOperator + s.TPAOperatorBaseline = u64p(800) + }}) + f.activate(signedCandidate(t, keyOperator, bundleOpts{Seq: 900})) + requireCoverage(t, false, "") + + // Back to the publisher root: the operator bundle is deactivated and the + // embedded default serves. No sequence is compared across authorities. + f.sec.TPAActiveRoot = config.TPARootPublisher + info := f.run() + require.Equal(t, BundleSourceEmbedded, info.Source) + requireCoverage(t, false, "") + }) +} + +// The degraded state is surfaced even when nothing about the serving bundle +// changed in a given run, and clears when a verified release activates. +func TestCoverage_ClearsWhenASignedReleaseActivates(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + f.put(unsignedCandidate(t, bundleOpts{Tag: "unsigned"})) + f.run() + requireCoverage(t, true, DegradedUnsignedExternalActive) + + f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 4})) + requireCoverage(t, false, "") +} diff --git a/internal/security/scanner/service.go b/internal/security/scanner/service.go index b062b73d6..5d2cd5958 100644 --- a/internal/security/scanner/service.go +++ b/internal/security/scanner/service.go @@ -426,7 +426,9 @@ func (s *Service) ApplySecurityConfig(sec *config.SecurityConfig) { // and re-read here, which is what makes it hot-reloadable. An empty path // means the corpus embedded in this build; a broken configured bundle keeps // the previously active corpus and surfaces the reason via BundleStatus(). - ConfigureBundle(sec.EffectiveTPABundlePath(), s.logger) + // Spec 101: the same call carries the trust policy -- require_signed_bundle, + // the active signing root, the operator signers/baseline/epoch. + ConfigureBundleFromConfig(sec, s.logger) enabled := sec.IsDeepScanEnabled() s.SetDeepScan(enabled, sec.DeepScanScanners()) @@ -446,6 +448,28 @@ func (s *Service) BundleStatus() BundleInfo { return BundleStatus() } +// BundleHistory returns the append-only TPA bundle activation history, oldest +// first (spec 101 FR-010/FR-020). +func (s *Service) BundleHistory() ([]TPABundleActivation, error) { + return BundleHistory() +} + +// RollbackBundle runs the explicit FR-010 rollback; see RollbackBundle. +func (s *Service) RollbackBundle(req BundleRollbackRequest) (BundleInfo, error) { + return RollbackBundle(req, s.logger) +} + +// UnpinBundle clears the active root's rollback pin; see UnpinBundle. +func (s *Service) UnpinBundle() (BundleInfo, error) { + return UnpinBundle(s.logger) +} + +// ReloadBundle re-runs the bundle pipeline with the current policy, so a +// file drop is picked up without waiting for a config change (spec 101 Δ5). +func (s *Service) ReloadBundle() BundleInfo { + return ReloadBundle(s.logger) +} + // isBaselineScanner reports whether a scanner id belongs to the deterministic // in-process baseline (Spec 077). An unknown id is treated as a deep scanner so // its failure is attributed to the deep-scan layer, never the baseline. diff --git a/internal/security/scanner/tpa_bundle.go b/internal/security/scanner/tpa_bundle.go index 181936123..3f194a9d4 100644 --- a/internal/security/scanner/tpa_bundle.go +++ b/internal/security/scanner/tpa_bundle.go @@ -9,8 +9,6 @@ import ( "strconv" "strings" - "go.uber.org/zap" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/security/detect" ) @@ -148,22 +146,6 @@ type bundleMeta struct { GeneratedAt string } -// bundleMetadata re-reads just the bundle header. It is only called on bytes -// that loadBundleCheck already parsed successfully, so a decode error here is -// impossible in practice and yields an empty header rather than a hard failure. -func bundleMetadata(data []byte) bundleMeta { - var b rawBundle - if err := json.Unmarshal(data, &b); err != nil { - return bundleMeta{} - } - return bundleMeta{ - BundleVersion: b.BundleVersion, - SchemaVersion: b.SchemaVersion, - SignatureCount: b.SignatureCount, - GeneratedAt: b.GeneratedAt, - } -} - // rawRule is one rule object. Only the fields the offline tier consumes are // modeled; any other key (indicators, flags, severity, type, rule, runtime …) // is ignored, keeping the loader forward-compatible. @@ -304,13 +286,7 @@ func loadEmbeddedBundleCheck() (*BundleCheck, bundleLoadStats, error) { // Configuration/logging live in tpa_bundle_source.go so this file stays about // parsing and matching. func defaultBundleCheck() *BundleCheck { - if check, info := snapshotBundle(); check != nil || info.LoadError != "" { - return check - } - // Never configured (e.g. a unit test constructing the engine directly): - // lazily install the embedded default so behavior is unchanged. - ConfigureBundle("", zap.NewNop()) - check, _ := snapshotBundle() + check, _ := activeBundleSnapshot() return check } diff --git a/internal/security/scanner/tpa_bundle_crash_test.go b/internal/security/scanner/tpa_bundle_crash_test.go new file mode 100644 index 000000000..483b1a35a --- /dev/null +++ b/internal/security/scanner/tpa_bundle_crash_test.go @@ -0,0 +1,238 @@ +package scanner + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" +) + +// crash makes a fault-injection seam abort the run the way a process death +// would: by panicking out of the pipeline. The fixture then simulates a restart +// over the same store and data directory. +func crash() { panic("simulated process death") } + +// --- FR-011a: crash points ------------------------------------------------------ + +// Fault after the LKG files are durable but before the state commit. The state +// is the old, consistent one; the half-installed LKG files are orphans. +func TestCrash_AfterLKGWriteBeforeCommit(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + a := signedCandidate(t, keyPublisher, bundleOpts{Seq: 5, Tag: "A"}) + f.activate(a) + b := signedCandidate(t, keyPublisher, bundleOpts{Seq: 6, Tag: "B"}) + f.put(b) + + lkgAfterWriteHook = crash + require.Panics(t, func() { f.run() }) + lkgAfterWriteHook = nil + + st := f.store.persisted(t) + assert.Equal(t, digestOf(a.Bundle), st.Active.FullDigest, "no commit happened, the pair is still (A, watermark 5)") + assert.Equal(t, uint64(5), st.Authorities[AuthorityPublisher].Watermark) + assert.Len(t, f.store.historyRows(), 1) + assert.Contains(t, f.lkgFiles(), digestOf(b.Bundle)+".json", "the candidate's LKG is durable before the commit") + + t.Run("restart with the candidate still at the path activates it cleanly", func(t *testing.T) { + f.restart() + info := f.run() + assert.Equal(t, "6", info.Sequence) + assert.Equal(t, uint64(6), f.store.persisted(t).Authorities[AuthorityPublisher].Watermark) + assert.Len(t, f.store.historyRows(), 2) + assert.ElementsMatch(t, []string{digestOf(b.Bundle) + ".json", digestOf(b.Bundle) + ".json.sig"}, f.lkgFiles()) + }) +} + +func TestCrash_AfterLKGWriteBeforeCommit_RestartWithoutTheFileServesLKG(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + a := signedCandidate(t, keyPublisher, bundleOpts{Seq: 5, Tag: "A"}) + f.activate(a) + b := signedCandidate(t, keyPublisher, bundleOpts{Seq: 6, Tag: "B"}) + f.put(b) + lkgAfterWriteHook = crash + require.Panics(t, func() { f.run() }) + lkgAfterWriteHook = nil + + f.restart() + f.remove() + info := f.run() + + assert.Equal(t, digestOf(a.Bundle), info.FullDigest, "the recorded active bundle serves from its LKG files") + assert.Equal(t, "5", info.Sequence) + assert.Equal(t, BundleSourceFile, info.Source) + assert.False(t, info.CoverageDegraded) + assert.NotContains(t, f.lkgFiles(), digestOf(b.Bundle)+".json", "the orphan from the interrupted activation is reconciled away") + assert.Contains(t, f.lkgFiles(), digestOf(a.Bundle)+".json") +} + +// Fault after the commit but before publication: the persisted pair is the new +// one, and a restart lands on it. +func TestCrash_AfterCommitBeforePublish(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 5, Tag: "A"})) + b := signedCandidate(t, keyPublisher, bundleOpts{Seq: 6, Tag: "B"}) + f.put(b) + + stateAfterCommitHook = crash + require.Panics(t, func() { f.run() }) + stateAfterCommitHook = nil + + st := f.store.persisted(t) + assert.Equal(t, digestOf(b.Bundle), st.Active.FullDigest) + assert.Equal(t, uint64(6), st.Authorities[AuthorityPublisher].Watermark) + assert.Len(t, f.store.historyRows(), 2, "state and history row were one transaction") + + f.restart() + f.remove() + info := f.run() + assert.Equal(t, digestOf(b.Bundle), info.FullDigest, "(bundle, watermark) is the consistent pair (B, 6)") + assert.Equal(t, "6", info.Sequence) + assert.True(t, info.SignatureVerified) +} + +// A rollback that survives a crash keeps its deny-list: the next refresh must +// not re-activate the release the operator just rejected. +func TestCrash_RollbackKeepsItsDenyList(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + a, b, aPath := twoReleases(t, f) + + stateAfterCommitHook = crash + require.Panics(t, func() { + _, _ = RollbackBundle(BundleRollbackRequest{Path: aPath, Digest: digestOf(a.Bundle)}, f.log) + }) + stateAfterCommitHook = nil + + f.restart() + // The configured path still holds release B, the one rolled away from. + info := f.run() + + assert.Equal(t, digestOf(a.Bundle), info.FullDigest, "the rollback target serves from its LKG files") + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectDenyListed, info.LastRejection.Reason, "B is refused: the deny-list was part of the same transaction") + assert.NotEqual(t, digestOf(b.Bundle), info.FullDigest) + assert.True(t, info.Pinned) + assert.True(t, info.Rollback) +} + +func TestCrash_FaultBeforeRollbackCommitChangesNothing(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + a, b, aPath := twoReleases(t, f) + + lkgAfterWriteHook = crash + require.Panics(t, func() { + _, _ = RollbackBundle(BundleRollbackRequest{Path: aPath, Digest: digestOf(a.Bundle)}, f.log) + }) + lkgAfterWriteHook = nil + + f.restart() + info := f.run() + assert.Equal(t, digestOf(b.Bundle), info.FullDigest, "an interrupted rollback never half-happened") + assert.False(t, info.Pinned) + assert.Empty(t, f.store.persisted(t).Authorities[AuthorityPublisher].DenyList) +} + +// --- LKG store -------------------------------------------------------------------- + +func TestLKG_StartupReconcilesOrphans(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + lkg := filepath.Join(f.dir, "tpa-lkg") + require.NoError(t, os.MkdirAll(lkg, 0o700)) + orphan := strings.Repeat("ab", 32) + outside := filepath.Join(t.TempDir(), "outside.json") + require.NoError(t, os.WriteFile(outside, []byte("precious"), 0o600)) + for name, content := range map[string]string{ + orphan + ".json": "orphan bundle", + orphan + ".json.sig": "orphan sidecar", + ".tmp-12345": "interrupted write", + "README.txt": "not ours, left alone", + } { + require.NoError(t, os.WriteFile(filepath.Join(lkg, name), []byte(content), 0o600)) + } + linkName := strings.Repeat("cd", 32) + ".json" + if err := os.Symlink(outside, filepath.Join(lkg, linkName)); err != nil { + t.Logf("symlinks unavailable: %v", err) + linkName = "" + } + + f.put(signedCandidate(t, keyPublisher, bundleOpts{Seq: 5})) + f.run() + + files := f.lkgFiles() + assert.NotContains(t, files, orphan+".json") + assert.NotContains(t, files, orphan+".json.sig") + assert.NotContains(t, files, ".tmp-12345") + assert.Contains(t, files, "README.txt", "files that are not LKG entries are never touched") + if linkName != "" { + assert.Contains(t, files, linkName, "a symlink is never followed or removed") + } + got, err := os.ReadFile(outside) + require.NoError(t, err) + assert.Equal(t, "precious", string(got), "nothing outside tpa-lkg is ever touched") +} + +func TestLKG_SubstitutedFileFallsBackToEmbedded(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + a := signedCandidate(t, keyPublisher, bundleOpts{Seq: 5, Tag: "A"}) + f.activate(a) + lkgFile := filepath.Join(f.dir, "tpa-lkg", digestOf(a.Bundle)+".json") + evil := signedCandidate(t, keyPublisher, bundleOpts{Seq: 99, Tag: "evil"}) + require.NoError(t, os.WriteFile(lkgFile, evil.Bundle, 0o600)) + + f.restart() + f.remove() + info := f.run() + + assert.Equal(t, BundleSourceEmbedded, info.Source, "bytes that do not hash to the recorded digest are treated as missing") + assert.Equal(t, embeddedDigest(), info.FullDigest) + assert.NotEmpty(t, f.logMessages(zapcore.WarnLevel)) + assert.Nil(t, f.store.persisted(t).Active, "the dead record is cleared") +} + +func TestLKG_NoDataDirMeansNoFiles(t *testing.T) { + f := newRTFixture(t, rtOpts{NoDataDir: true}) + f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 5})) + _, err := os.Stat(filepath.Join(f.dir, "tpa-lkg")) + assert.True(t, os.IsNotExist(err)) + + // The in-memory pointer is the only LKG: it survives the file going away. + f.remove() + info := f.run() + assert.Equal(t, "5", info.Sequence) + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectReadError, info.LastRejection.Reason) +} + +func TestLKG_RemovedFileKeepsServingWhilePathStaysConfigured(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + a := signedCandidate(t, keyPublisher, bundleOpts{Seq: 5}) + f.activate(a) + f.remove() + + info := f.run() + + assert.Equal(t, digestOf(a.Bundle), info.FullDigest, "last-known-good keeps serving") + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectReadError, info.LastRejection.Reason) + assert.Contains(t, info.LoadError, RejectReadError) + assert.False(t, info.CoverageDegraded) +} + +func TestLKG_ClearingThePathFallsBackToEmbeddedAndClearsActive(t *testing.T) { + f := newRTFixture(t, rtOpts{EmbeddedSeq: 3}) + f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 5})) + f.sec.TPABundlePath = "" + + info := f.run() + + assert.Equal(t, BundleSourceEmbedded, info.Source) + assert.Empty(t, info.LoadError) + assert.Nil(t, f.store.persisted(t).Active) + assert.Len(t, f.store.historyRows(), 1, "falling back is not an activation") + assert.True(t, info.CoverageDegraded, "embedded sequence 3 is below the retained watermark 5") + assert.Equal(t, DegradedEmbeddedBelowWatermark, info.DegradedReason) + assert.Empty(t, f.lkgFiles(), "no live record references an LKG file any more") +} diff --git a/internal/security/scanner/tpa_bundle_fifo_unix_test.go b/internal/security/scanner/tpa_bundle_fifo_unix_test.go new file mode 100644 index 000000000..ddad06726 --- /dev/null +++ b/internal/security/scanner/tpa_bundle_fifo_unix_test.go @@ -0,0 +1,51 @@ +//go:build unix + +package scanner + +import ( + "os" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// SC-011 at the pipeline level: a FIFO planted at the bundle path is refused +// immediately, last-known-good keeps serving, and the next legitimate drop +// activates. (The open layer is covered by candidate_open_unix_test.go.) +func TestRuntime_FIFOAtTheBundlePath(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + prev := f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 5})) + chk := servingCheck() + + require.NoError(t, os.Remove(f.path)) + require.NoError(t, syscall.Mkfifo(f.path, 0o600)) + + start := time.Now() + info := f.run() + assert.Less(t, time.Since(start), 3*time.Second, "a never-written FIFO is refused, not waited on") + f.requireRejected(info, RejectNotRegularFile, prev.FullDigest, chk) + + require.NoError(t, os.Remove(f.path)) + info = f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 6, Tag: "next"})) + assert.Equal(t, "6", info.Sequence, "the next legitimate drop activates") + require.NotNil(t, info.LastRejection, "and the refusal stays visible") + assert.Equal(t, RejectNotRegularFile, info.LastRejection.Reason) +} + +// The sidecar path gets the same treatment: a FIFO there must not be mistaken +// for "no sidecar" and so must not fall through to the unsigned path. +func TestRuntime_FIFOAtTheSidecarPathIsNotAnUnsignedDrop(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + c := unsignedCandidate(t, bundleOpts{Seq: 5}) + f.put(c) + require.NoError(t, syscall.Mkfifo(f.path+".sig", 0o600)) + + info := f.run() + + assert.Equal(t, embeddedDigest(), info.FullDigest) + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectNotRegularFile, info.LastRejection.Reason) +} diff --git a/internal/security/scanner/tpa_bundle_lkg.go b/internal/security/scanner/tpa_bundle_lkg.go new file mode 100644 index 000000000..0e8b0b3ca --- /dev/null +++ b/internal/security/scanner/tpa_bundle_lkg.go @@ -0,0 +1,240 @@ +package scanner + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" +) + +// Content-addressed last-known-good (LKG) store (spec 101 FR-011a, research +// D6). The serving external bundle is durable in /tpa-lkg so a +// restart can recover the (bundle, watermark) pair the persisted state names +// even when the operator-configured file has since changed or vanished. +// +// Files are named by the SHA-256 of their bytes: .json for the bundle +// and .json.sig for the sidecar. A file whose bytes do not hash to its +// name is treated as missing, so substituting an LKG file can only downgrade +// the install to the embedded default, never put attacker bytes into service. +// +// Everything here is best-effort against an attacker who controls the data +// directory (the spec's threat-model boundary, SC-012); what it guarantees is +// crash consistency, not tamper-proofness. + +const ( + lkgDirName = "tpa-lkg" + lkgTempPrefix = ".tmp-" +) + +// Fault-injection seams (FR-011a). Production code leaves both nil; a crash +// test aborts the run at the named boundary and then simulates a restart over +// the same store and data directory. +var ( + // lkgAfterWriteHook runs once the LKG files are durable and before the + // state commit. + lkgAfterWriteHook func() + // stateAfterCommitHook runs once the state is committed and before the new + // bundle is published. + stateAfterCommitHook func() +) + +// digestHexPattern is the only shape a digest may have before it is allowed to +// become part of a file name: the persisted state is not trusted to be +// path-safe. +var digestHexPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + +func lkgDirPath(dataDir string) string { return filepath.Join(dataDir, lkgDirName) } + +// errLKGSubstituted marks an LKG file whose bytes do not hash to the digest the +// state recorded for it. +var errLKGSubstituted = errors.New("last-known-good file does not match its recorded digest") + +// lkgWrite makes one bundle (and its sidecar, when signed) durable: +// +// temp file in the same directory -> write -> fsync -> close -> rename to the +// final name -> fsync the directory. +// +// It is idempotent: a final file that already holds the same bytes is left +// alone. Only after lkgWrite returns may the state commit and the publish +// happen (the recoverable LKG must be durable before the pointer moves). +func lkgWrite(dataDir, digest string, bundle, sidecar []byte, signed bool) error { + if !digestHexPattern.MatchString(digest) { + return fmt.Errorf("refusing to name an LKG file after %q", digest) + } + dir := lkgDirPath(dataDir) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + if err := lkgWriteFile(dir, digest+".json", bundle); err != nil { + return err + } + sigPath := filepath.Join(dir, digest+".json.sig") + if signed { + if err := lkgWriteFile(dir, digest+".json.sig", sidecar); err != nil { + return err + } + } else if err := os.Remove(sigPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return lkgSyncDir(dir) +} + +// lkgWriteFile writes data to dir/name via a synced temp file and a rename. +func lkgWriteFile(dir, name string, data []byte) error { + final := filepath.Join(dir, name) + if st, err := os.Lstat(final); err == nil && st.Mode().IsRegular() { + if existing, rerr := os.ReadFile(final); rerr == nil && bytes.Equal(existing, data) { //nolint:gosec // path is dataDir/tpa-lkg/ + return nil + } + } + tmp, err := os.CreateTemp(dir, lkgTempPrefix+"*") + if err != nil { + return err + } + tmpName := tmp.Name() + cleanup := func() { _ = os.Remove(tmpName) } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + cleanup() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + cleanup() + return err + } + if err := tmp.Close(); err != nil { + cleanup() + return err + } + if err := os.Rename(tmpName, final); err != nil { + cleanup() + return err + } + return nil +} + +// lkgSyncDir fsyncs the directory so the renames are durable. Directory fsync +// is not supported on Windows (opening a directory handle for sync fails +// there); NTFS journals the rename itself, so it is skipped. +func lkgSyncDir(dir string) error { + if runtime.GOOS == "windows" { + return nil + } + d, err := os.Open(dir) //nolint:gosec // dataDir/tpa-lkg + if err != nil { + return err + } + syncErr := d.Sync() + closeErr := d.Close() + if syncErr != nil { + return syncErr + } + return closeErr +} + +// lkgRead returns the candidate the state's Active record names, read through +// the same descriptor-first, ceiling-bounded reader as a file drop, and checked +// against the recorded digests. +func lkgRead(ctx context.Context, dataDir string, rec *TPABundleActiveRecord) (Candidate, error) { + if !digestHexPattern.MatchString(rec.FullDigest) { + return Candidate{}, errLKGSubstituted + } + dir := lkgDirPath(dataDir) + bundle, err := readCandidateFile(ctx, filepath.Join(dir, rec.FullDigest+".json"), MaxBundleBytes) + if err != nil { + return Candidate{}, err + } + if digestHex(bundle) != rec.FullDigest { + return Candidate{}, errLKGSubstituted + } + c := Candidate{Bundle: bundle, Source: rec.Source, Path: rec.Path} + if rec.SignatureVerified { + sidecar, err := readCandidateFile(ctx, filepath.Join(dir, rec.FullDigest+".json.sig"), MaxSidecarBytes) + if err != nil { + return Candidate{}, err + } + if digestHex(sidecar) != rec.SidecarDigest { + return Candidate{}, errLKGSubstituted + } + c.Sidecar, c.SidecarPresent = sidecar, true + } + return c, nil +} + +// lkgReferenced is the set of digests the state still needs on disk: the +// serving record and every authority's rollback pin. +func lkgReferenced(st *TPABundleState) map[string]struct{} { + keep := map[string]struct{}{} + if st == nil { + return keep + } + if st.Active != nil { + keep[st.Active.FullDigest] = struct{}{} + } + for _, a := range st.Authorities { + if a != nil && a.Pin != nil { + keep[a.Pin.FullDigest] = struct{}{} + } + } + return keep +} + +// lkgReconcile removes LKG files and leftover temp files that no live state +// record references: the debris of an interrupted activation, a superseded +// release, or a refused candidate. It never deletes outside the tpa-lkg +// directory, never follows a symlink (non-regular entries are skipped) and +// leaves alone any name that is not an LKG entry. It returns the removed names. +func lkgReconcile(dataDir string, st *TPABundleState) ([]string, error) { + dir := lkgDirPath(dataDir) + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + keep := lkgReferenced(st) + var removed []string + var firstErr error + for _, e := range entries { + if !e.Type().IsRegular() { + continue + } + name := e.Name() + remove := false + switch { + case strings.HasPrefix(name, lkgTempPrefix): + remove = true + case strings.HasSuffix(name, ".json.sig"): + remove = lkgOrphan(strings.TrimSuffix(name, ".json.sig"), keep) + case strings.HasSuffix(name, ".json"): + remove = lkgOrphan(strings.TrimSuffix(name, ".json"), keep) + } + if !remove { + continue + } + if err := os.Remove(filepath.Join(dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) { + if firstErr == nil { + firstErr = err + } + continue + } + removed = append(removed, name) + } + return removed, firstErr +} + +// lkgOrphan reports whether stem is a well-formed digest nothing references. +func lkgOrphan(stem string, keep map[string]struct{}) bool { + if !digestHexPattern.MatchString(stem) { + return false + } + _, referenced := keep[stem] + return !referenced +} diff --git a/internal/security/scanner/tpa_bundle_rollback.go b/internal/security/scanner/tpa_bundle_rollback.go new file mode 100644 index 000000000..87426f493 --- /dev/null +++ b/internal/security/scanner/tpa_bundle_rollback.go @@ -0,0 +1,238 @@ +package scanner + +import ( + "errors" + "fmt" + "time" + + "go.uber.org/zap" +) + +// Explicit rollback and unpin (spec 101 FR-010, design §7). Rollback is the one +// path that deliberately bypasses the downgrade watermark, so it is authorized +// by the COMPLETE SHA-256 digest of the target bytes, never by a pathname or a +// 12-hex fingerprint: the path only says where to look for those bytes. + +// BundleRollbackRequest names the rollback target. Exactly one of Embedded and +// Path is set; Digest is the full lowercase-hex SHA-256 of the target's bytes. +type BundleRollbackRequest struct { + // Embedded rolls back to the corpus compiled into this binary. + Embedded bool `json:"embedded,omitempty"` + // Path is where to find the target bundle (and Path+".sig"); a hint only. + Path string `json:"path,omitempty"` + // Digest is the 64-hex SHA-256 the loaded artifact must have. + Digest string `json:"digest"` +} + +// ErrBundleRollbackUsage marks a malformed rollback request: both or neither of +// embedded/path, or a digest that is not 64 lowercase hex characters (a 12-hex +// fingerprint included). It is a caller error (HTTP 400), not a recorded +// rejection. +var ErrBundleRollbackUsage = errors.New("invalid bundle rollback request") + +// BundleRollbackRefused is returned when the target was refused after the +// request was well-formed. The refusal is also recorded as the install's +// LastRejection; last-known-good keeps serving. +type BundleRollbackRefused struct{ Rejection BundleRejection } + +func (e *BundleRollbackRefused) Error() string { + if e.Rejection.Detail == "" { + return "bundle rollback refused: " + e.Rejection.Reason + } + return "bundle rollback refused: " + e.Rejection.Reason + ": " + e.Rejection.Detail +} + +// validateRollbackRequest enforces the request grammar. +func validateRollbackRequest(req BundleRollbackRequest) error { + if req.Embedded == (req.Path != "") { + return fmt.Errorf("%w: give exactly one of embedded or path", ErrBundleRollbackUsage) + } + if !digestHexPattern.MatchString(req.Digest) { + return fmt.Errorf("%w: digest must be the full SHA-256 as 64 lowercase hex characters (the 12-character fingerprint is a label, not an authorization)", ErrBundleRollbackUsage) + } + return nil +} + +// RollbackBundle performs the explicit rollback of FR-010. On success the +// target serves, is pinned, the release rolled away from is deny-listed, the +// watermark is untouched and the override is logged at WARN. A refusal returns +// a *BundleRollbackRefused (also recorded as LastRejection); a malformed +// request returns ErrBundleRollbackUsage. The returned BundleInfo is the status +// after the call in every case. +func RollbackBundle(req BundleRollbackRequest, logger *zap.Logger) (BundleInfo, error) { + if err := validateRollbackRequest(req); err != nil { + return BundleStatus(), err + } + log := bundleLogger(logger) + bundleRT.mu.Lock() + defer bundleRT.mu.Unlock() + err := bundleRT.rollbackLocked(req, log) + return statusFromSnapshot(), err +} + +func (rt *bundleRuntime) rollbackLocked(req BundleRollbackRequest, log *zap.Logger) error { + now := time.Now() + if rt.policy == nil || !rt.loaded { + // Nothing has run yet: establish the baseline serving state first so + // there is something to roll away from. + rt.runLocked(rt.policyOrDefaultLocked(), log, runOptions{}) + } + policy := rt.policy + nst, normLogs := normalizeState(rt.state, policy) + for _, line := range normLogs { + log.Warn(line) + } + refuse := func(rej *BundleRejection, digest string) error { + rt.recordRejectionLocked(rej, digest, true) + rt.republishRejectionLocked() + rt.clean = false + log.Error("TPA scanner bundle rollback refused; the serving corpus is unchanged", + zap.String("reason", rej.Reason), zap.String("detail", rej.Detail), zap.String("named_digest", req.Digest)) + return &BundleRollbackRefused{Rejection: *rej} + } + + if rt.loadFailed { + // A rollback is the one operator override of the watermark; recording + // it is the whole point (pin + deny-list), so it is not performed + // against a state store that could not be read. + return refuse(&BundleRejection{Reason: RejectStateCommitFailed, + Detail: "the anti-downgrade state could not be loaded, so the rollback cannot be recorded", At: now}, "") + } + + var ( + sv *servingBundle + target rollbackTarget + ) + if req.Embedded { + e := rt.embeddedLocked() + if e.err != nil { + return refuse(&BundleRejection{Reason: RejectInvalidBundle, Detail: "embedded bundle unavailable: " + e.err.Error(), At: now}, e.digest) + } + if e.digest != req.Digest { + return refuse(&BundleRejection{Reason: RejectRollbackTargetMismatch, + Detail: fmt.Sprintf("named %s, the embedded default is %s", shortDigest(req.Digest), shortDigest(e.digest)), At: now}, e.digest) + } + seq := uint64(0) + if e.hasSeq { + seq = e.seq + } + target = embeddedRollbackTarget(policy, e.digest, seq, e.meta.GeneratedAt) + sv = embeddedServing(e, policy) + } else { + cand, rej := readFileCandidate(req.Path) + if rej != nil { + return refuse(rej, "") + } + got := digestHex(cand.Bundle) + // The full digest is compared BEFORE anything is parsed or verified: the + // path is attacker-writable, so the bytes themselves must prove they are + // the artifact the operator named. + if got != req.Digest { + return refuse(&BundleRejection{Reason: RejectRollbackTargetMismatch, + Detail: fmt.Sprintf("named %s, the file holds %s", shortDigest(req.Digest), shortDigest(got)), At: now}, got) + } + acc, rej := evaluateCandidate(*cand, evalEnv{Policy: policy, State: nst, Mode: evalRollback, RollbackDigest: req.Digest}) + if rej != nil { + return refuse(rej, got) + } + sv = newExternalServing(*cand, acc) + acc.Meta.GeneratedAt = sv.generatedAt + target = rollbackTargetFromAccepted(acc, *cand) + } + + // The release being rolled away from is whatever was serving; rolling TO the + // serving release must never deny-list it. + rolledAway := nst.Active + if rolledAway != nil && rolledAway.FullDigest == target.FullDigest { + rolledAway = nil + } + st, row := applyRollback(nst, policy, target, rolledAway, now) + + var external *servingBundle + if !sv.embedded { + external = sv + } + if err := rt.persistLocked(st, row, external, log); err != nil { + return refuse(&BundleRejection{Reason: RejectStateCommitFailed, Detail: err.Error(), At: now}, target.FullDigest) + } + + rt.policy = policy + rt.active = external + rt.clean = false + rt.publishLocked(policy, st, sv, log, now) + + fields := []zap.Field{ + zap.String("target_full_digest", target.FullDigest), + zap.Uint64("target_sequence", target.Sequence), + zap.String("target_source", target.Source), + zap.Bool("target_embedded", target.Embedded), + } + if rolledAway != nil { + fields = append(fields, zap.String("rolled_away_full_digest", rolledAway.FullDigest), zap.Uint64("rolled_away_sequence", rolledAway.Sequence)) + } + if a := st.Authorities[policy.ActiveRoot]; a != nil { + fields = append(fields, zap.Uint64("watermark", a.Watermark)) + } + log.Warn("TPA scanner bundle ROLLBACK: operator override", fields...) + return nil +} + +// republishRejectionLocked re-publishes the already serving (check, info) with +// the freshly recorded last rejection, without touching anything else. +func (rt *bundleRuntime) republishRejectionLocked() { + check, info := snapshotBundle() + info.LastRejection = rt.lastRejCopy() + storeBundle(check, info) +} + +// UnpinBundle clears the rollback pin of the active signing root (FR-010) and +// re-validates the serving bundle WITHOUT it: a rolled-back target below the +// watermark no longer passes and the install falls back to the embedded default +// under the FR-009a rules, exactly as a restart would. The deny-list is kept. +// Unpinning when nothing is pinned is a quiet no-op. +func UnpinBundle(logger *zap.Logger) (BundleInfo, error) { + log := bundleLogger(logger) + bundleRT.mu.Lock() + defer bundleRT.mu.Unlock() + rt := &bundleRT + + if rt.policy == nil || !rt.loaded { + rt.runLocked(rt.policyOrDefaultLocked(), log, runOptions{}) + } + policy := rt.policy + if rt.loadFailed { + return statusFromSnapshot(), errors.New("unpin not recorded: the anti-downgrade state could not be loaded") + } + nst, _ := normalizeState(rt.state, policy) + a := nst.Authorities[policy.ActiveRoot] + if a == nil || a.Pin == nil { + return statusFromSnapshot(), nil + } + pin := *a.Pin + st := cloneState(nst) + st.Authorities[policy.ActiveRoot].Pin = nil + if err := rt.persistLocked(st, nil, nil, log); err != nil { + return statusFromSnapshot(), fmt.Errorf("unpin not recorded: %w", err) + } + log.Warn("TPA scanner bundle pin removed", + zap.String("effect", "activation is governed by the watermark again"), + zap.String("authority", policy.ActiveRoot), + zap.String("pinned_full_digest", pin.FullDigest), + zap.Uint64("pinned_sequence", pin.Sequence)) + rt.runLocked(policy, log, runOptions{force: true}) + return statusFromSnapshot(), nil +} + +// BundleHistory returns the append-only activation history, oldest first. It is +// how an operator learns the full digest of a known-good artifact to name in a +// rollback; it makes a legitimate rollback possible, it never authorizes one. +func BundleHistory() ([]TPABundleActivation, error) { + bundleRT.histMu.Lock() + store := bundleRT.histStore + mem := append([]TPABundleActivation(nil), bundleRT.history...) + bundleRT.histMu.Unlock() + if store != nil { + return store.ListTPABundleHistory() + } + return mem, nil +} diff --git a/internal/security/scanner/tpa_bundle_rollback_test.go b/internal/security/scanner/tpa_bundle_rollback_test.go new file mode 100644 index 000000000..02a78090b --- /dev/null +++ b/internal/security/scanner/tpa_bundle_rollback_test.go @@ -0,0 +1,330 @@ +package scanner + +import ( + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// twoReleases activates release A (sequence 5) then B (sequence 6), keeping A's +// bytes in a separate file the operator can name for a rollback. +func twoReleases(t *testing.T, f *rtFixture) (a, b Candidate, aPath string) { + t.Helper() + a = signedCandidate(t, keyPublisher, bundleOpts{Seq: 5, Tag: "A"}) + b = signedCandidate(t, keyPublisher, bundleOpts{Seq: 6, Tag: "B"}) + aPath = f.drop + "/previous-release.json" + f.putAt(aPath, a) + f.activate(a) + f.activate(b) + return a, b, aPath +} + +func TestRollback_ToFileByFullDigest(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + a, b, aPath := twoReleases(t, f) + + info, err := RollbackBundle(BundleRollbackRequest{Path: aPath, Digest: digestOf(a.Bundle)}, f.log) + require.NoError(t, err) + + assert.Equal(t, digestOf(a.Bundle), info.FullDigest) + assert.Equal(t, "5", info.Sequence) + assert.True(t, info.Rollback) + assert.True(t, info.Pinned) + assert.True(t, info.SignatureVerified) + assert.Equal(t, BundleSourceFile, info.Source) + assert.Contains(t, f.logMessages(zapcore.WarnLevel), "TPA scanner bundle ROLLBACK: operator override") + + st := f.store.persisted(t) + pub := st.Authorities[AuthorityPublisher] + assert.Equal(t, uint64(6), pub.Watermark, "the watermark is not lowered") + assert.Equal(t, digestOf(b.Bundle), pub.WatermarkSetBy) + require.NotNil(t, pub.Pin) + assert.Equal(t, digestOf(a.Bundle), pub.Pin.FullDigest) + require.Len(t, pub.DenyList, 1) + assert.Equal(t, digestOf(b.Bundle), pub.DenyList[0].FullDigest) + rows := f.store.historyRows() + require.Len(t, rows, 3) + assert.True(t, rows[2].Rollback) + assert.Equal(t, "5", rows[2].Sequence) + assert.Contains(t, f.lkgFiles(), digestOf(a.Bundle)+".json", "the rollback target is durable") +} + +func TestRollback_DenyListedReleaseIsNotReactivatedByTheNextReload(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + a, b, aPath := twoReleases(t, f) + _, err := RollbackBundle(BundleRollbackRequest{Path: aPath, Digest: digestOf(a.Bundle)}, f.log) + require.NoError(t, err) + + // The configured path still holds release B, the one rolled away from. + info := ReloadBundle(f.log) + + assert.Equal(t, digestOf(a.Bundle), info.FullDigest, "the pinned target keeps serving") + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectDenyListed, info.LastRejection.Reason) + assert.NotEqual(t, digestOf(b.Bundle), info.FullDigest) + assert.True(t, info.Pinned) + assert.Len(t, f.store.historyRows(), 3, "a refusal is not an activation") +} + +func TestRollback_PinGovernsIntermediateReleasesUntilANewerOneArrives(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + a := signedCandidate(t, keyPublisher, bundleOpts{Seq: 5, Tag: "A"}) + aPath := f.drop + "/a.json" + f.putAt(aPath, a) + f.activate(a) + f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 8, Tag: "B"})) + _, err := RollbackBundle(BundleRollbackRequest{Path: aPath, Digest: digestOf(a.Bundle)}, f.log) + require.NoError(t, err) + + f.put(signedCandidate(t, keyPublisher, bundleOpts{Seq: 6, Tag: "intermediate"})) + info := f.run() + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectDowngrade, info.LastRejection.Reason, "an intermediate release is refused while the pin binds") + assert.Equal(t, digestOf(a.Bundle), info.FullDigest) + + newer := signedCandidate(t, keyPublisher, bundleOpts{Seq: 9, Tag: "newer"}) + info = f.activate(newer) + assert.Equal(t, "9", info.Sequence) + assert.False(t, info.Pinned, "a genuinely newer release supersedes the pin") + assert.False(t, info.Rollback) +} + +func TestRollback_Unpin(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + a, _, aPath := twoReleases(t, f) + _, err := RollbackBundle(BundleRollbackRequest{Path: aPath, Digest: digestOf(a.Bundle)}, f.log) + require.NoError(t, err) + + info, err := UnpinBundle(f.log) + require.NoError(t, err) + + assert.False(t, info.Pinned) + assert.False(t, info.Rollback) + assert.Equal(t, BundleSourceEmbedded, info.Source, + "without the pin the rolled-back release sits below the watermark and the install falls back to the embedded default, as a restart would") + assert.True(t, info.CoverageDegraded) + assert.Equal(t, DegradedEmbeddedBelowWatermark, info.DegradedReason) + pub := f.store.persisted(t).Authorities[AuthorityPublisher] + assert.Nil(t, pub.Pin) + assert.Len(t, pub.DenyList, 1, "unpinning does not forgive the rolled-away release") + assert.Equal(t, uint64(6), pub.Watermark) + assert.Contains(t, f.logMessages(zapcore.WarnLevel), "TPA scanner bundle pin removed") + assert.Len(t, f.store.historyRows(), 3, "unpin is not an activation") + + // Unpinning when nothing is pinned is a quiet no-op. + commits := f.store.commitCount() + _, err = UnpinBundle(f.log) + require.NoError(t, err) + assert.Equal(t, commits, f.store.commitCount()) +} + +func TestRollback_UsageErrors(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + a, _, aPath := twoReleases(t, f) + prev := BundleStatus() + good := digestOf(a.Bundle) + + cases := map[string]BundleRollbackRequest{ + "both embedded and path": {Embedded: true, Path: aPath, Digest: good}, + "neither embedded nor path": {Digest: good}, + "12-hex fingerprint": {Path: aPath, Digest: good[:12]}, + "uppercase digest": {Path: aPath, Digest: strings.ToUpper(good)}, + "63 hex digits": {Path: aPath, Digest: good[:63]}, + "non-hex digest": {Path: aPath, Digest: strings.Repeat("z", 64)}, + "empty digest": {Path: aPath}, + } + for name, req := range cases { + t.Run(name, func(t *testing.T) { + _, err := RollbackBundle(req, f.log) + require.Error(t, err) + assert.ErrorIs(t, err, ErrBundleRollbackUsage) + var refused *BundleRollbackRefused + assert.False(t, errors.As(err, &refused), "a usage error is not a recorded rejection") + }) + } + assert.Equal(t, prev, BundleStatus(), "usage errors change nothing") +} + +func TestRollback_EmbeddedDigestMismatchIsRecordedAndReturned(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + _, b, _ := twoReleases(t, f) + chk := servingCheck() + + info, err := RollbackBundle(BundleRollbackRequest{Embedded: true, Digest: strings.Repeat("a", 64)}, f.log) + + var refused *BundleRollbackRefused + require.ErrorAs(t, err, &refused) + assert.Equal(t, RejectRollbackTargetMismatch, refused.Rejection.Reason) + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectRollbackTargetMismatch, info.LastRejection.Reason) + assert.Equal(t, digestOf(b.Bundle), info.FullDigest, "serving unchanged") + assert.Same(t, chk, servingCheck()) +} + +// SC-010: a REAL 48-bit fingerprint collision. B shares A's 12-hex fingerprint +// and nothing else; neither the fingerprint nor the path may authorize the +// rollback, only the full digest. +func TestRollback_SC010_FingerprintCollisionIsRefused(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + bytesA, bytesB := sc010Bundle(sc010NonceA), sc010Bundle(sc010NonceB) + candA := Candidate{Bundle: bytesA, Sidecar: signBundle(t, keyPublisher.priv, bytesA), SidecarPresent: true} + candB := Candidate{Bundle: bytesB, Sidecar: signBundle(t, keyPublisher.priv, bytesB), SidecarPresent: true} + require.Equal(t, sc010DigestA, digestOf(bytesA)) + require.Equal(t, sc010DigestB, digestOf(bytesB)) + + // A is known-good (active, then superseded by a sequence-6 release). + f.activate(candA) + release6 := signedCandidate(t, keyPublisher, bundleOpts{Seq: 6, Tag: "six"}) + info := f.activate(release6) + chk := servingCheck() + + // The operator names a path; an attacker has put B (same fingerprint) there. + named := f.drop + "/rollback-target.json" + f.putAt(named, candB) + + t.Run("a 12-hex fingerprint is a usage error", func(t *testing.T) { + _, err := RollbackBundle(BundleRollbackRequest{Path: named, Digest: sc010Fingerprint}, f.log) + assert.ErrorIs(t, err, ErrBundleRollbackUsage) + }) + + t.Run("A's full digest over B's bytes is a target mismatch", func(t *testing.T) { + got, err := RollbackBundle(BundleRollbackRequest{Path: named, Digest: sc010DigestA}, f.log) + var refused *BundleRollbackRefused + require.ErrorAs(t, err, &refused) + assert.Equal(t, RejectRollbackTargetMismatch, refused.Rejection.Reason) + assert.Equal(t, info.FullDigest, got.FullDigest, "serving unchanged") + assert.Same(t, chk, servingCheck()) + assert.Equal(t, uint64(6), f.store.persisted(t).Authorities[AuthorityPublisher].Watermark) + assert.Nil(t, f.store.persisted(t).Authorities[AuthorityPublisher].Pin, "a refused rollback pins nothing") + assert.Len(t, f.store.historyRows(), 2) + }) + + t.Run("A's full digest with A's bytes succeeds", func(t *testing.T) { + f.putAt(named, candA) + got, err := RollbackBundle(BundleRollbackRequest{Path: named, Digest: sc010DigestA}, f.log) + require.NoError(t, err) + assert.Equal(t, sc010DigestA, got.FullDigest) + assert.True(t, got.Rollback) + assert.True(t, got.Pinned) + assert.Equal(t, uint64(6), f.store.persisted(t).Authorities[AuthorityPublisher].Watermark) + }) +} + +func TestRollback_OperatorTargetIsMeasuredAgainstTheOperatorBaseline(t *testing.T) { + f := newRTFixture(t, rtOpts{Sec: func(s *config.SecurityConfig) { + s.TPAActiveRoot = config.TPARootOperator + s.TPAOperatorBaseline = u64p(10) + }}) + a := signedCandidate(t, keyOperator, bundleOpts{Seq: 11, Tag: "A"}) + aPath := f.drop + "/a.json" + f.putAt(aPath, a) + f.activate(a) + f.activate(signedCandidate(t, keyOperator, bundleOpts{Seq: 12, Tag: "B"})) + chk := servingCheck() + + // A genuinely operator-signed bundle BELOW the operator baseline cannot be + // reached through the rollback bypass. + low := signedCandidate(t, keyOperator, bundleOpts{Seq: 8, Tag: "low"}) + lowPath := f.drop + "/low.json" + f.putAt(lowPath, low) + _, err := RollbackBundle(BundleRollbackRequest{Path: lowPath, Digest: digestOf(low.Bundle)}, f.log) + var refused *BundleRollbackRefused + require.ErrorAs(t, err, &refused) + assert.Equal(t, RejectDowngrade, refused.Rejection.Reason) + assert.Contains(t, refused.Rejection.Detail, "below operator baseline 10") + assert.Same(t, chk, servingCheck()) + + // Sequence 11 clears the baseline. + info, err := RollbackBundle(BundleRollbackRequest{Path: aPath, Digest: digestOf(a.Bundle)}, f.log) + require.NoError(t, err) + assert.Equal(t, "11", info.Sequence) + assert.Equal(t, AuthorityOperator, info.Authority) + assert.False(t, info.CoverageDegraded) +} + +func TestRollback_BaselinelessOperatorProceedsDegraded(t *testing.T) { + f := newRTFixture(t, rtOpts{Sec: func(s *config.SecurityConfig) { s.TPAActiveRoot = config.TPARootOperator }}) + a := signedCandidate(t, keyOperator, bundleOpts{Seq: 3, Tag: "A"}) + aPath := f.drop + "/a.json" + f.putAt(aPath, a) + f.activate(a) + f.activate(signedCandidate(t, keyOperator, bundleOpts{Seq: 4, Tag: "B"})) + + info, err := RollbackBundle(BundleRollbackRequest{Path: aPath, Digest: digestOf(a.Bundle)}, f.log) + + require.NoError(t, err) + assert.Equal(t, "3", info.Sequence) + assert.True(t, info.CoverageDegraded) + assert.Equal(t, DegradedOperatorAuthorityUnbaselined, info.DegradedReason) +} + +func TestRollback_RolledBackToTheServingBundleDoesNotDenyListIt(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + a := signedCandidate(t, keyPublisher, bundleOpts{Seq: 5, Tag: "A"}) + f.activate(a) + + info, err := RollbackBundle(BundleRollbackRequest{Path: f.path, Digest: digestOf(a.Bundle)}, f.log) + + require.NoError(t, err) + assert.Equal(t, digestOf(a.Bundle), info.FullDigest) + assert.Empty(t, f.store.persisted(t).Authorities[AuthorityPublisher].DenyList, + "the release being rolled TO is never the one denied") + assert.Equal(t, digestOf(a.Bundle), ReloadBundle(f.log).FullDigest) +} + +// SC-010 replay: the history is durable, ordered, and survives a restart. +func TestRollback_HistoryReplaysAcrossRestart(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + const n = 12 + var digests []string + for seq := uint64(1); seq <= n; seq++ { + c := signedCandidate(t, keyPublisher, bundleOpts{Seq: seq, Tag: "h"}) + f.activate(c) + digests = append(digests, digestOf(c.Bundle)) + } + + check := func() { + rows, err := BundleHistory() + require.NoError(t, err) + require.Len(t, rows, n) + for i, row := range rows { + assert.Equal(t, digests[i], row.FullDigest, "row %d", i) + assert.Equal(t, AuthorityPublisher, row.Authority) + assert.True(t, row.SignatureVerified) + assert.Equal(t, BundleSourceFile, row.Source) + } + } + check() + f.restart() + check() + // A restart plus a refresh of the unchanged file adds nothing. + f.run() + check() +} + +// A rollback and an unpin exist to RECORD an override (pin, deny-list); against a +// state store that cannot be read they are refused rather than silently applied. +func TestRollback_RefusedWhileTheStateIsUnreadable(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + a, _, aPath := twoReleases(t, f) + + f.store.failLoad = errors.New("bolt: timeout") + f.restart() + f.run() + prev := BundleStatus() + + _, err := RollbackBundle(BundleRollbackRequest{Path: aPath, Digest: digestOf(a.Bundle)}, f.log) + var refused *BundleRollbackRefused + require.ErrorAs(t, err, &refused) + assert.Equal(t, RejectStateCommitFailed, refused.Rejection.Reason) + + _, err = UnpinBundle(f.log) + assert.Error(t, err) + assert.Equal(t, prev.FullDigest, BundleStatus().FullDigest, "refusals never change what serves") +} diff --git a/internal/security/scanner/tpa_bundle_runtime.go b/internal/security/scanner/tpa_bundle_runtime.go new file mode 100644 index 000000000..144ca46cc --- /dev/null +++ b/internal/security/scanner/tpa_bundle_runtime.go @@ -0,0 +1,808 @@ +package scanner + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "strconv" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// The serialized bundle runtime (spec 101 FR-008, FR-009, FR-011a; design §2/§6). +// +// The loader is process-global because the scan gate also runs where no +// scanner.Service exists (stdio mode). One mutex, bundleRT.mu, serializes EVERY +// pipeline run (startup, config reload, explicit reload, rollback, unpin), so +// two runs can never interleave their read -> validate -> persist -> publish +// steps. Scans never take that mutex: they read the published (check, info) +// snapshot under activeBundleMu, and a run publishes both under one lock at its +// very end. + +// bundleReadBudget is the per-candidate read budget (FR-011: 5 s for a local +// file). It is a variable only so a test can shrink it. +var bundleReadBudget = 5 * time.Second + +// scannerLogName is the logger name every bundle log line carries. +const scannerLogName = "security.scanner" + +// servingBundle is the corpus a run chose to serve. +type servingBundle struct { + // embedded is true for the binary's own default. + embedded bool + // cand holds the exact bytes of an external bundle, kept in memory so the + // serving bundle can be re-validated under a new policy without touching the + // filesystem. + cand Candidate + acc *acceptedBundle + + check *BundleCheck + stats bundleLoadStats + meta bundleMeta + + digest string + sidecarDigest string + authority string + verified bool + sequence uint64 + hasSequence bool + epoch uint64 + // generatedAt is the freshness stamp surfaced in BundleInfo (the manifest's + // own, or for an unsigned file the file's mtime). + generatedAt string +} + +// id identifies the serving bundle across runs; it decides LoadedAt and the +// "loaded" log line. +func (s *servingBundle) id() string { + if s.embedded { + return "embedded|" + s.digest + } + return s.digest + "|" + s.sidecarDigest + "|" + s.authority +} + +// embeddedEntry caches the compiled embedded default, keyed by the digest of the +// embedded bytes, so a refused candidate never costs a recompile. +type embeddedEntry struct { + digest string + check *BundleCheck + stats bundleLoadStats + meta bundleMeta + seq uint64 + hasSeq bool + err error +} + +// bundleRuntime is the process-wide runtime state. +type bundleRuntime struct { + // mu is the activation mutex: it serializes every run. It is never held + // while activeBundleMu is held, and activeBundleMu is never held across I/O. + mu sync.Mutex + + store TPABundleStateStore // nil => in-memory state only (unit tests, scan-eval) + dataDir string // "" => no LKG files + + // state mirrors the last COMMITTED (or, with no store, applied) state. + state *TPABundleState + loaded bool + // loadFailed is true while the state store could not be read: the run + // proceeds against the out-of-data-dir anchors but records nothing. + loadFailed bool + + policy *bundlePolicy + active *servingBundle // the serving EXTERNAL bundle; nil => embedded serves + + lastRej *BundleRejection + lastRejKey string + loadErr string + // clean is true when the last run completed without a persistence failure, + // i.e. repeating it with identical inputs is a no-op. + clean bool + + servingID string + loadedAt time.Time + lastLogKey string + degradedKey string + warnedFP string + + embedded *embeddedEntry + + // histMu guards history and histStore, so BundleHistory never waits on a + // run that is blocked in a (budget-bounded) file read. + histMu sync.Mutex + histStore TPABundleStateStore // mirror of store for BundleHistory + history []TPABundleActivation // kept only when store == nil +} + +var bundleRT bundleRuntime + +// resetLocked returns the runtime to its zero state (as a fresh process would +// find it). The published snapshot is left to the caller. +func (rt *bundleRuntime) resetLocked() { + rt.store = nil + rt.dataDir = "" + rt.state = nil + rt.loaded = false + rt.loadFailed = false + rt.policy = nil + rt.active = nil + rt.lastRej = nil + rt.lastRejKey = "" + rt.loadErr = "" + rt.clean = false + rt.servingID = "" + rt.loadedAt = time.Time{} + rt.lastLogKey = "" + rt.degradedKey = "" + rt.warnedFP = "" + rt.embedded = nil + rt.histMu.Lock() + rt.history = nil + rt.histStore = nil + rt.histMu.Unlock() +} + +// SetBundleRuntime injects the persistent state store and the data directory the +// LKG files live under. Call it once at startup, before the first configure; it +// resets the runtime so the next run loads state from the new store (spec 101 +// Δ4). A nil store keeps the anti-downgrade state in memory only; an empty +// dataDir keeps no LKG files. +func SetBundleRuntime(store TPABundleStateStore, dataDir string) { + bundleRT.mu.Lock() + defer bundleRT.mu.Unlock() + bundleRT.resetLocked() + bundleRT.store = store + bundleRT.dataDir = dataDir + bundleRT.histMu.Lock() + bundleRT.histStore = store + bundleRT.histMu.Unlock() +} + +// ConfigureBundleFromConfig applies the security config's bundle policy: the +// path (MCPPROXY_TPA_BUNDLE_PATH outranks the file value), require_signed_bundle, +// the active signing root, the operator signers, baseline and epoch. It is safe +// to call repeatedly and from any goroutine; an unchanged configuration with an +// unchanged bundle file is a no-op (no commit, no history row, no log line). +func ConfigureBundleFromConfig(sec *config.SecurityConfig, logger *zap.Logger) { + configureWithPolicy(policyFromConfig(sec), logger) +} + +// configureWithPolicy runs the pipeline for an already-built policy. +func configureWithPolicy(policy *bundlePolicy, logger *zap.Logger) { + log := bundleLogger(logger) + bundleRT.mu.Lock() + defer bundleRT.mu.Unlock() + bundleRT.runLocked(policy, log, runOptions{}) +} + +// ReloadBundle re-runs the pipeline with the last applied policy and returns the +// resulting status. It is the single-flight refresh the REST/CLI reload action +// and Spec 087's fetch tick share (FR-018). +func ReloadBundle(logger *zap.Logger) BundleInfo { + log := bundleLogger(logger) + bundleRT.mu.Lock() + defer bundleRT.mu.Unlock() + bundleRT.runLocked(bundleRT.policyOrDefaultLocked(), log, runOptions{}) + return statusFromSnapshot() +} + +// BundleCoverageDegraded reports whether the active bundle demotes the install to +// degraded coverage, and why. The scan-mode admission gates consult it: while it +// is true auto-approval is suspended and changes route to human review. +func BundleCoverageDegraded() (bool, string) { + _, info := activeBundleSnapshot() + return info.CoverageDegraded, info.DegradedReason +} + +func bundleLogger(logger *zap.Logger) *zap.Logger { + if logger == nil { + logger = zap.NewNop() + } + return logger.Named(scannerLogName) +} + +// policyOrDefaultLocked is the last applied policy, or the embedded-only default +// when nothing has been configured yet. +func (rt *bundleRuntime) policyOrDefaultLocked() *bundlePolicy { + if rt.policy != nil { + return rt.policy + } + return policyFromConfig(nil) +} + +// runOptions tunes one run. +type runOptions struct { + // force skips the idempotence short-circuit (unpin re-validates the serving + // bundle even though no input file changed). + force bool +} + +// selection is what a run chose to serve and why. +type selection struct { + serving *servingBundle + // fromPath is true when serving is the freshly accepted configured-path + // candidate (an activation unless it equals the recorded active record). + fromPath bool + // attempt is the refusal of the configured-path candidate; attemptDigest is + // the candidate's digest when it was read ("" for a read failure). + attempt *BundleRejection + attemptDigest string + // deactivated is the refusal of the bundle that WAS serving, when it no + // longer passes validation under the current policy and state. + deactivated *BundleRejection + deactivatedDigest string +} + +// runLocked is one pipeline run (design §6). The caller holds rt.mu. +func (rt *bundleRuntime) runLocked(policy *bundlePolicy, log *zap.Logger, opts runOptions) { + now := time.Now() + rt.ensureLoadedLocked(log) + rt.warnPolicyLocked(policy, log) + + var ( + cand *Candidate + readRej *BundleRejection + ) + if policy.Path != "" { + cand, readRej = readFileCandidate(policy.Path) + } + if !opts.force && rt.canSkipLocked(policy, cand) { + return + } + + nst, normLogs := normalizeState(rt.state, policy) + for _, line := range normLogs { + log.Warn(line) + } + + sel := rt.chooseLocked(policy, nst, cand, readRej, log) + + st := nst + var row *TPABundleActivation + activation := false + needCommit := len(normLogs) > 0 + switch { + case sel.fromPath && !sameActive(nst.Active, sel.serving): + activation = true + sel.serving.acc.Meta.GeneratedAt = sel.serving.generatedAt + st, row = applyActivation(nst, sel.serving.acc, sel.serving.cand, now) + case sel.serving != nil && sel.serving.embedded && nst.Active != nil: + st = cloneState(nst) + st.Active = nil + needCommit = true + } + + clean := !rt.loadFailed + switch { + case activation: + if err := rt.persistLocked(st, row, sel.serving, log); err != nil { + // Refusing to activate is the safe outcome: activating without a + // durable watermark would reopen the replay window FR-011a exists + // to close. Last-known-good keeps serving. + rej := &BundleRejection{Reason: RejectStateCommitFailed, Detail: err.Error(), At: time.Now()} + clean = false + st = nst + failed := sel + sel = selection{attempt: rej, attemptDigest: failed.serving.digest} + rt.fallbackLocked(policy, nst, &sel, log) + if sel.serving != nil && sel.serving.embedded && nst.Active != nil { + st = cloneState(nst) + st.Active = nil + if perr := rt.persistLocked(st, nil, nil, log); perr != nil { + log.Error("could not record the fall-back to the embedded TPA scanner bundle", zap.Error(perr)) + st = nst + } + } + } + case needCommit: + if err := rt.persistLocked(st, nil, nil, log); err != nil { + clean = false + st = nst + log.Error("could not persist the TPA bundle anti-downgrade state", zap.Error(err)) + } + case sel.fromPath && rt.dataDir != "" && !rt.loadFailed: + // The path serves the very bundle the state records; make sure its LKG + // files exist (a wiped tpa-lkg directory heals here). + if err := lkgWrite(rt.dataDir, sel.serving.digest, sel.serving.cand.Bundle, sel.serving.cand.Sidecar, sel.serving.cand.SidecarPresent); err != nil { + log.Error("could not refresh the last-known-good copy of the serving TPA bundle", zap.Error(err)) + } + } + + rt.finishLocked(policy, st, sel, clean, log, now) +} + +// finishLocked records rejections, then builds and publishes the status. +func (rt *bundleRuntime) finishLocked(policy *bundlePolicy, st *TPABundleState, sel selection, clean bool, log *zap.Logger, now time.Time) { + loadErr := "" + if policy.Path != "" && sel.attempt != nil { + loadErr = sel.attempt.Reason + ": " + sel.attempt.Detail + if rt.recordRejectionLocked(sel.attempt, sel.attemptDigest, false) { + log.Error("configured TPA scanner bundle refused; keeping the last-known-good corpus", + zap.String("path", policy.Path), + zap.String("reason", sel.attempt.Reason), + zap.String("detail", sel.attempt.Detail), + zap.String("candidate_digest", sel.attemptDigest)) + } + } + if sel.deactivated != nil { + rt.recordRejectionLocked(sel.deactivated, sel.deactivatedDigest, false) + } + + // rt.state is only ever advanced by persistLocked, i.e. by a transition + // that was actually recorded; the normalized view (st) is recomputed on + // every run and is used here for status only. + rt.policy = policy + rt.clean = clean + rt.loadErr = loadErr + if sel.serving == nil || sel.serving.embedded { + rt.active = nil + } else { + rt.active = sel.serving + } + + rt.publishLocked(policy, st, sel.serving, log, now) +} + +// ensureLoadedLocked loads the persisted state the first time a run needs it +// (and again after a failed load), then reconciles the LKG directory. +func (rt *bundleRuntime) ensureLoadedLocked(log *zap.Logger) { + if rt.loaded { + return + } + rt.loadFailed = false + if rt.store != nil { + st, err := rt.store.LoadTPABundleState() + if err != nil { + // Start from nil: the anchors outside the data directory (publisher + // baseline, operator baseline, require_signed, root declaration) + // still hold. Nothing is recorded against a state that could not be + // read, and the load is retried on the next run. + log.Error("could not load the TPA bundle anti-downgrade state; running against the config and binary anchors only", + zap.Error(err)) + rt.state = nil + rt.loadFailed = true + return + } + rt.state = st + } + rt.loaded = true + rt.reconcileLocked(rt.state, log) +} + +// reconcileLocked deletes LKG debris no live state record references. +func (rt *bundleRuntime) reconcileLocked(st *TPABundleState, log *zap.Logger) { + if rt.dataDir == "" || rt.loadFailed { + return + } + removed, err := lkgReconcile(rt.dataDir, st) + if err != nil { + log.Warn("could not fully reconcile the TPA last-known-good directory", zap.Error(err)) + } + if len(removed) > 0 { + log.Info("removed unreferenced TPA last-known-good files", zap.Strings("files", removed)) + } +} + +// warnPolicyLocked logs the dropped-signer notes once per distinct policy. +func (rt *bundleRuntime) warnPolicyLocked(policy *bundlePolicy, log *zap.Logger) { + if rt.warnedFP == policy.Fingerprint { + return + } + rt.warnedFP = policy.Fingerprint + for _, w := range policy.Warnings { + log.Warn("TPA bundle trust configuration: " + w) + } +} + +// canSkipLocked is the idempotence short-circuit (design §6.3): the same policy +// and the same bytes already serving mean there is nothing to decide, commit, +// record or log. +func (rt *bundleRuntime) canSkipLocked(policy *bundlePolicy, cand *Candidate) bool { + if !rt.clean || rt.policy == nil || rt.policy.Fingerprint != policy.Fingerprint || rt.loadErr != "" { + return false + } + if policy.Path == "" { + return rt.active == nil + } + if cand == nil || rt.active == nil { + return false + } + return rt.active.cand.SidecarPresent == cand.SidecarPresent && + bytes.Equal(rt.active.cand.Bundle, cand.Bundle) && + bytes.Equal(rt.active.cand.Sidecar, cand.Sidecar) +} + +// chooseLocked picks what serves, first that succeeds (design §6.4): +// +// a. the configured-path candidate (read once, evaluated in normal mode); +// b. the bundle already serving, re-validated under the new policy and state; +// c. the active record from the LKG files (a fresh process); +// d. the embedded default. +// +// With no configured path the operator removed the external bundle, so b and c +// are skipped too. +func (rt *bundleRuntime) chooseLocked(policy *bundlePolicy, nst *TPABundleState, cand *Candidate, readRej *BundleRejection, log *zap.Logger) selection { + var sel selection + if policy.Path != "" { + switch { + case readRej != nil: + sel.attempt = readRej + default: + acc, rej := evaluateCandidate(*cand, evalEnv{Policy: policy, State: nst, Mode: evalNormal}) + if rej != nil { + sel.attempt, sel.attemptDigest = rej, digestHex(cand.Bundle) + } else { + sel.serving = newExternalServing(*cand, acc) + sel.fromPath = true + return sel + } + } + } + rt.fallbackLocked(policy, nst, &sel, log) + return sel +} + +// fallbackLocked fills sel.serving from steps b, c and d. +func (rt *bundleRuntime) fallbackLocked(policy *bundlePolicy, nst *TPABundleState, sel *selection, log *zap.Logger) { + sel.serving, sel.fromPath = nil, false + if policy.Path != "" { + switch { + case rt.active != nil: + acc, rej := evaluateCandidate(rt.active.cand, evalEnv{Policy: policy, State: nst, Mode: evalLKG, Reuse: rt.active.acc}) + if rej == nil { + sv := newExternalServing(rt.active.cand, acc) + sv.generatedAt = rt.active.generatedAt + sel.serving = sv + return + } + sel.deactivated, sel.deactivatedDigest = rej, rt.active.digest + log.Warn("serving TPA scanner bundle deactivated: it no longer passes validation under the current policy", + zap.String("full_digest", rt.active.digest), + zap.String("reason", rej.Reason), + zap.String("detail", rej.Detail)) + case nst.Active != nil && rt.dataDir != "" && !rt.loadFailed: + if sv, rej := rt.restoreFromLKGLocked(policy, nst, log); sv != nil { + sel.serving = sv + return + } else if rej != nil { + sel.deactivated, sel.deactivatedDigest = rej, nst.Active.FullDigest + } + } + } + + e := rt.embeddedLocked() + if e.err != nil { + sel.serving = nil + log.Warn("embedded TPA scanner bundle failed to load; continuing without bundle-backed checks", zap.Error(e.err)) + return + } + sel.serving = embeddedServing(e, policy) +} + +// restoreFromLKGLocked re-validates the state's Active record from its LKG +// files. A missing or substituted file, or a record that no longer validates, +// yields a rejection and no serving bundle. +func (rt *bundleRuntime) restoreFromLKGLocked(policy *bundlePolicy, nst *TPABundleState, log *zap.Logger) (*servingBundle, *BundleRejection) { + rec := nst.Active + ctx, cancel := context.WithTimeout(context.Background(), bundleReadBudget) + defer cancel() + cand, err := lkgRead(ctx, rt.dataDir, rec) + if err != nil { + rej := &BundleRejection{At: time.Now()} + var ce *candidateReadError + switch { + case errors.Is(err, errLKGSubstituted): + rej.Reason, rej.Detail = RejectTamper, "last-known-good file does not match its recorded digest "+shortDigest(rec.FullDigest) + case errors.As(err, &ce): + rej.Reason, rej.Detail = ce.Reason, "last-known-good: "+ce.Detail + default: + rej.Reason, rej.Detail = RejectReadError, "last-known-good: "+err.Error() + } + log.Warn("recorded last-known-good TPA bundle is unavailable; falling back to the embedded default", + zap.String("full_digest", rec.FullDigest), zap.String("reason", rej.Reason), zap.String("detail", rej.Detail)) + return nil, rej + } + acc, rej := evaluateCandidate(cand, evalEnv{Policy: policy, State: nst, Mode: evalLKG}) + if rej != nil { + log.Warn("recorded last-known-good TPA bundle no longer passes validation; falling back to the embedded default", + zap.String("full_digest", rec.FullDigest), zap.String("reason", rej.Reason), zap.String("detail", rej.Detail)) + return nil, rej + } + sv := newExternalServing(cand, acc) + sv.generatedAt = rec.GeneratedAt + return sv, nil +} + +// persistLocked makes one transition durable and then publishes nothing: LKG +// files first (when an external bundle is entering service), then the single +// state transaction (state + history row), with the crash seams between. On +// success it updates the state mirror and reconciles the LKG directory. +func (rt *bundleRuntime) persistLocked(st *TPABundleState, row *TPABundleActivation, sv *servingBundle, log *zap.Logger) error { + if sv != nil && !sv.embedded && rt.dataDir != "" && !rt.loadFailed { + if err := lkgWrite(rt.dataDir, sv.digest, sv.cand.Bundle, sv.cand.Sidecar, sv.cand.SidecarPresent); err != nil { + rt.reconcileLocked(rt.state, log) + return fmt.Errorf("last-known-good write: %w", err) + } + } + if lkgAfterWriteHook != nil { + lkgAfterWriteHook() + } + if rt.store != nil { + // With the state unreadable (loadFailed) nothing is recorded: the run + // serves against the config and binary anchors only. + if !rt.loadFailed { + if err := rt.store.CommitTPABundleState(st, row); err != nil { + rt.reconcileLocked(rt.state, log) + return fmt.Errorf("state commit: %w", err) + } + } + } else if row != nil { + rt.histMu.Lock() + rt.history = append(rt.history, *row) + rt.histMu.Unlock() + } + rt.state = st + if stateAfterCommitHook != nil { + stateAfterCommitHook() + } + rt.reconcileLocked(st, log) + return nil +} + +// recordRejectionLocked stores rej as the last rejection. A repeat of the same +// (reason, digest) is NOT re-recorded (so a file that stays bad does not churn +// the timestamp or the log) unless force is set. It reports whether rej was new. +func (rt *bundleRuntime) recordRejectionLocked(rej *BundleRejection, digest string, force bool) bool { + key := rej.Reason + "|" + digest + if !force && rt.lastRej != nil && rt.lastRejKey == key { + return false + } + cp := *rej + rt.lastRej, rt.lastRejKey = &cp, key + return true +} + +// publishLocked builds the BundleInfo for sv and publishes (check, info) under +// one activeBundleMu acquisition. +func (rt *bundleRuntime) publishLocked(policy *bundlePolicy, st *TPABundleState, sv *servingBundle, log *zap.Logger, now time.Time) { + if sv == nil { + // The embedded default itself is broken: as before, serve without + // bundle-backed checks and say so. + e := rt.embeddedLocked() + msg := "embedded bundle unavailable" + if e.err != nil { + msg = e.err.Error() + } + storeBundle(nil, BundleInfo{Source: BundleSourceEmbedded, LoadedAt: now, LoadError: msg, LastRejection: rt.lastRejCopy()}) + return + } + + if id := sv.id(); id != rt.servingID { + rt.servingID, rt.loadedAt = id, now + } + info := rt.buildInfoLocked(policy, st, sv) + storeBundle(sv.check, info) + + key := fmt.Sprintf("%s|%s|%s|%t|%s|%t|%t", info.Source, info.FullDigest, info.Authority, info.CoverageDegraded, info.DegradedReason, info.Pinned, info.Rollback) + if key != rt.lastLogKey { + rt.lastLogKey = key + logBundle(log, info) + } + rt.logDegradedLocked(log, info) +} + +func (rt *bundleRuntime) lastRejCopy() *BundleRejection { + if rt.lastRej == nil { + return nil + } + cp := *rt.lastRej + return &cp +} + +// logDegradedLocked logs every transition into, between and out of degraded +// coverage; a silently suspended auto-approval is the failure FR-020 exists to +// prevent. +func (rt *bundleRuntime) logDegradedLocked(log *zap.Logger, info BundleInfo) { + key := "" + if info.CoverageDegraded { + key = info.DegradedReason + if key == "" { + key = "degraded" + } + } + if key == rt.degradedKey { + return + } + prev := rt.degradedKey + rt.degradedKey = key + switch { + case key != "": + log.Warn("TPA scanner bundle coverage degraded: scan-mode auto-approval is suspended and changes route to human review", + zap.String("degraded_reason", key), + zap.String("source", info.Source), + zap.String("authority", info.Authority), + zap.String("full_digest", info.FullDigest)) + case prev != "": + log.Info("TPA scanner bundle coverage restored", zap.String("previous_degraded_reason", prev)) + } +} + +// buildInfoLocked renders the operator-facing status of sv (design §6.6). +func (rt *bundleRuntime) buildInfoLocked(policy *bundlePolicy, st *TPABundleState, sv *servingBundle) BundleInfo { + info := BundleInfo{ + BundleVersion: sv.meta.BundleVersion, + SchemaVersion: sv.meta.SchemaVersion, + SignatureCount: sv.meta.SignatureCount, + RunnableRules: sv.stats.Runnable, + SkippedRules: sv.stats.Skipped, + DeclaredSkipped: sv.stats.Declared, + GeneratedAt: sv.generatedAt, + FullDigest: sv.digest, + Fingerprint: shortDigest(sv.digest), + LoadedAt: rt.loadedAt, + Authority: sv.authority, + TrustEpoch: sv.epoch, + SignatureVerified: sv.verified, + LastRejection: rt.lastRejCopy(), + LoadError: rt.loadErr, + } + if sv.hasSequence { + info.Sequence = strconv.FormatUint(sv.sequence, 10) + } + if sv.embedded { + info.Source = BundleSourceEmbedded + } else { + info.Source = BundleSourceFile + info.Path = sv.cand.Path + } + + embeddedSeq := uint64(0) + if e := rt.embeddedLocked(); e.err == nil && e.hasSeq { + embeddedSeq = e.seq + } + info.CoverageDegraded, info.DegradedReason = computeCoverage( + servingFacts{Embedded: sv.embedded, Verified: sv.verified, Authority: sv.authority}, st, policy, embeddedSeq) + + var pin *TPABundlePin + if a := st.Authorities[policy.ActiveRoot]; a != nil { + pin = a.Pin + } + info.Pinned = pin != nil + if sv.embedded { + info.Rollback = pin != nil && pin.Embedded + } else { + info.Rollback = st.Active != nil && st.Active.Rollback && st.Active.FullDigest == sv.digest + } + return info +} + +// embeddedLocked returns the compiled embedded default, compiling it at most +// once per distinct embedded byte string. +func (rt *bundleRuntime) embeddedLocked() *embeddedEntry { + digest := digestHex(bundledScannerBundle) + if rt.embedded != nil && rt.embedded.digest == digest { + return rt.embedded + } + e := &embeddedEntry{digest: digest} + m, err := parseBundleManifest(bundledScannerBundle) + if err == nil { + e.meta = bundleMeta{ + BundleVersion: m.BundleVersion, + SchemaVersion: m.SchemaVersion, + SignatureCount: m.SignatureCount, + GeneratedAt: m.GeneratedAt, + } + if seq, _, serr := parseSequence(m.Sequence); serr == nil { + e.seq, e.hasSeq = seq, true + } + e.check, e.stats, err = compileBundle(m) + } + e.err = err + rt.embedded = e + return e +} + +// embeddedServing wraps the embedded default as a serving bundle. It is a +// publisher artifact that rides on binary integrity, so it is never +// "signature verified". +func embeddedServing(e *embeddedEntry, policy *bundlePolicy) *servingBundle { + return &servingBundle{ + embedded: true, + check: e.check, + stats: e.stats, + meta: e.meta, + digest: e.digest, + authority: AuthorityPublisher, + sequence: e.seq, + hasSequence: e.hasSeq, + epoch: policy.currentEpoch(AuthorityPublisher), + generatedAt: e.meta.GeneratedAt, + } +} + +// newExternalServing wraps an accepted external candidate. +func newExternalServing(cand Candidate, acc *acceptedBundle) *servingBundle { + sv := &servingBundle{ + cand: cand, + acc: acc, + check: acc.Check, + stats: acc.Stats, + meta: acc.Meta, + digest: acc.FullDigest, + sidecarDigest: acc.SidecarDigest, + authority: acc.Authority, + verified: acc.Verified, + epoch: acc.TrustEpoch, + generatedAt: acc.Meta.GeneratedAt, + } + if acc.Verified { + sv.sequence, sv.hasSequence = acc.Sequence, true + } + if sv.generatedAt == "" && cand.Path != "" { + // The v0.1.0 bundle format carries no generated_at; the file's mtime is + // the next-best freshness signal for an unsigned drop and is strictly + // better than nothing. A signed bundle's freshness comes from its + // signed manifest only. + if !acc.Verified { + if st, err := os.Stat(cand.Path); err == nil { + sv.generatedAt = st.ModTime().UTC().Format(time.RFC3339) + } + } + } + return sv +} + +// sameActive reports whether the state's active record already names sv. +func sameActive(rec *TPABundleActiveRecord, sv *servingBundle) bool { + return rec != nil && sv != nil && !sv.embedded && + rec.FullDigest == sv.digest && + rec.SidecarDigest == sv.sidecarDigest && + rec.Authority == sv.authority && + rec.SignatureVerified == sv.verified +} + +// readFileCandidate reads the configured bundle and its sidecar under ONE read +// budget (design §6.4a). An absent sidecar (ENOENT only) means "unsigned"; any +// other sidecar failure is a rejection, never a fallback to the unsigned path. +func readFileCandidate(path string) (*Candidate, *BundleRejection) { + ctx, cancel := context.WithTimeout(context.Background(), bundleReadBudget) + defer cancel() + + sidecar, serr := readCandidateFile(ctx, path+".sig", MaxSidecarBytes) + present := true + if serr != nil { + var ce *candidateReadError + if errors.As(serr, &ce) && ce.NotExist { + present, sidecar = false, nil + } else { + return nil, readRejection(serr, "sidecar") + } + } + data, berr := readCandidateFile(ctx, path, MaxBundleBytes) + if berr != nil { + return nil, readRejection(berr, "bundle") + } + return &Candidate{Bundle: data, Sidecar: sidecar, SidecarPresent: present, Source: BundleSourceFile, Path: path}, nil +} + +// readRejection turns a candidate read failure into a recorded rejection. +func readRejection(err error, what string) *BundleRejection { + rej := &BundleRejection{At: time.Now()} + var ce *candidateReadError + if errors.As(err, &ce) { + rej.Reason = ce.Reason + rej.Detail = ce.Detail + if ce.Reason == RejectCeilingExceeded { + rej.Detail = what + ": " + ce.Detail + } + return rej + } + rej.Reason, rej.Detail = RejectReadError, err.Error() + return rej +} diff --git a/internal/security/scanner/tpa_bundle_runtime_test.go b/internal/security/scanner/tpa_bundle_runtime_test.go new file mode 100644 index 000000000..36fab704d --- /dev/null +++ b/internal/security/scanner/tpa_bundle_runtime_test.go @@ -0,0 +1,625 @@ +package scanner + +import ( + "bytes" + "errors" + "io" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// --- Spec 101 US1 Independent Test, end to end through ConfigureBundleFromConfig --- + +func TestRuntime_SignedBundleActivates(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + c := signedCandidate(t, keyPublisher, bundleOpts{Seq: 5, GeneratedAt: "2026-10-01T00:00:00Z"}) + f.put(c) + + info := f.run() + + assert.Equal(t, BundleSourceFile, info.Source) + assert.Equal(t, f.path, info.Path) + assert.True(t, info.SignatureVerified) + assert.Equal(t, "5", info.Sequence) + assert.Equal(t, AuthorityPublisher, info.Authority) + assert.Equal(t, uint64(0), info.TrustEpoch) + assert.Equal(t, digestOf(c.Bundle), info.FullDigest) + assert.Equal(t, digestOf(c.Bundle)[:12], info.Fingerprint) + assert.Equal(t, "2026-10-01T00:00:00Z", info.GeneratedAt) + assert.Equal(t, 1, info.RunnableRules) + assert.False(t, info.CoverageDegraded, "a verified publisher bundle is full coverage") + assert.Empty(t, info.LoadError) + assert.Nil(t, info.LastRejection) + assert.False(t, info.Pinned) + assert.False(t, info.Rollback) + + st := f.store.persisted(t) + require.NotNil(t, st) + assert.True(t, st.SignatureRatchet) + require.NotNil(t, st.Active) + assert.Equal(t, digestOf(c.Bundle), st.Active.FullDigest) + assert.Equal(t, uint64(5), st.Authorities[AuthorityPublisher].Watermark) + assert.Equal(t, digestOf(c.Bundle), st.Authorities[AuthorityPublisher].WatermarkSetBy) + + rows := f.store.historyRows() + require.Len(t, rows, 1, "one activation, one history row") + assert.Equal(t, "5", rows[0].Sequence) + assert.True(t, rows[0].SignatureVerified) + assert.Equal(t, BundleSourceFile, rows[0].Source) + assert.Equal(t, 1, f.store.commitCount()) + + assert.ElementsMatch(t, []string{digestOf(c.Bundle) + ".json", digestOf(c.Bundle) + ".json.sig"}, f.lkgFiles(), + "the activated release is durable in the content-addressed LKG store") +} + +func TestRuntime_US1_RejectionsKeepLastKnownGood(t *testing.T) { + // base activates a publisher release at sequence 10 and returns its identity. + base := func(t *testing.T, o rtOpts) (*rtFixture, BundleInfo, *BundleCheck) { + f := newRTFixture(t, o) + info := f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 10, Tag: "base"})) + return f, info, servingCheck() + } + + t.Run("(a) one flipped byte", func(t *testing.T) { + f, prev, chk := base(t, rtOpts{}) + good := signedCandidate(t, keyPublisher, bundleOpts{Seq: 11, Tag: "good"}) + n := countCompiles(t) + f.put(Candidate{Bundle: flipByte(t, good.Bundle, "bundle-marker"), Sidecar: good.Sidecar, SidecarPresent: true}) + info := f.run() + f.requireRejected(info, RejectTamper, prev.FullDigest, chk) + assert.Zero(t, *n, "a tampered candidate is refused before any pattern is compiled") + }) + + t.Run("(b) signature from a non-trusted key", func(t *testing.T) { + f, prev, chk := base(t, rtOpts{}) + f.put(signedCandidate(t, keyUntrusted, bundleOpts{Seq: 11})) + info := f.run() + f.requireRejected(info, RejectKeyNotTrusted, prev.FullDigest, chk) + assert.Contains(t, info.LastRejection.Detail, keyUntrusted.id) + }) + + t.Run("(c) missing sidecar with require_signed_bundle", func(t *testing.T) { + f, prev, chk := base(t, rtOpts{Sec: func(s *config.SecurityConfig) { s.RequireSignedBundle = true }}) + f.put(unsignedCandidate(t, bundleOpts{Seq: 11})) + info := f.run() + f.requireRejected(info, RejectSignatureRequired, prev.FullDigest, chk) + }) + + t.Run("(c) the same on a fresh install keeps the embedded default serving", func(t *testing.T) { + f := newRTFixture(t, rtOpts{Sec: func(s *config.SecurityConfig) { s.RequireSignedBundle = true }}) + f.put(unsignedCandidate(t, bundleOpts{Seq: 11})) + info := f.run() + assert.Equal(t, BundleSourceEmbedded, info.Source) + assert.Equal(t, embeddedDigest(), info.FullDigest) + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectSignatureRequired, info.LastRejection.Reason) + assert.False(t, info.CoverageDegraded, "refusing a candidate does not degrade the fresh-install embedded default") + }) + + t.Run("(d) lower sequence than the active bundle", func(t *testing.T) { + f, prev, chk := base(t, rtOpts{}) + f.put(signedCandidate(t, keyPublisher, bundleOpts{Seq: 9, Tag: "older"})) + info := f.run() + f.requireRejected(info, RejectDowngrade, prev.FullDigest, chk) + assert.Contains(t, info.LastRejection.Detail, "sequence 9") + assert.Contains(t, info.LastRejection.Detail, "watermark 10") + assert.Equal(t, uint64(10), f.store.persisted(t).Authorities[AuthorityPublisher].Watermark, "a refusal never moves the watermark") + }) + + t.Run("(e) failing the activation self-check", func(t *testing.T) { + f, prev, chk := base(t, rtOpts{}) + withSelfCheck(t, func(*BundleCheck) error { return errors.New("self-check said no") }) + f.put(signedCandidate(t, keyPublisher, bundleOpts{Seq: 11})) + info := f.run() + f.requireRejected(info, RejectGateFailure, prev.FullDigest, chk) + assert.Contains(t, info.LastRejection.Detail, "self-check said no") + }) + + t.Run("(f) unsigned drop after a signed bundle was active (ratchet)", func(t *testing.T) { + f, prev, chk := base(t, rtOpts{}) + f.put(unsignedCandidate(t, bundleOpts{Seq: 11})) + info := f.run() + f.requireRejected(info, RejectUnsignedRatchet, prev.FullDigest, chk) + }) + + t.Run("(g) unsigned bundle claiming a huge sequence never becomes the watermark", func(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + huge := unsignedCandidate(t, bundleOpts{SeqRaw: `"18446744073709551615"`, Tag: "huge"}) + f.put(huge) + info := f.run() + + assert.Equal(t, BundleSourceFile, info.Source, "an unsigned drop is accepted by default") + assert.False(t, info.SignatureVerified) + assert.Empty(t, info.Sequence, "the attacker-chosen manifest sequence is never surfaced as authoritative") + assert.True(t, info.CoverageDegraded) + assert.Equal(t, DegradedUnsignedExternalActive, info.DegradedReason) + st := f.store.persisted(t) + assert.False(t, st.SignatureRatchet) + assert.Equal(t, uint64(0), st.Authorities[AuthorityPublisher].Watermark, "watermark unchanged") + + legit := signedCandidate(t, keyPublisher, bundleOpts{Seq: 7, Tag: "legit"}) + info = f.activate(legit) + assert.Equal(t, "7", info.Sequence, "a later legitimate signed release still activates") + assert.True(t, info.SignatureVerified) + assert.False(t, info.CoverageDegraded) + }) + + t.Run("(h) fresh install, publisher-signed bundle older than the embedded corpus", func(t *testing.T) { + f := newRTFixture(t, rtOpts{EmbeddedSeq: 100}) + f.put(signedCandidate(t, keyPublisher, bundleOpts{Seq: 5})) + info := f.run() + + assert.Equal(t, BundleSourceEmbedded, info.Source) + assert.Equal(t, embeddedDigest(), info.FullDigest) + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectDowngrade, info.LastRejection.Reason) + assert.Contains(t, info.LastRejection.Detail, "watermark 100") + assert.False(t, info.CoverageDegraded, "the embedded default at the baseline is full coverage") + }) + + t.Run("(i) operator bundle while the publisher authority is active", func(t *testing.T) { + f, prev, chk := base(t, rtOpts{Sec: func(s *config.SecurityConfig) { s.TPAOperatorBaseline = u64p(1) }}) + f.put(signedCandidate(t, keyOperator, bundleOpts{Seq: 50, Tag: "operator"})) + info := f.run() + f.requireRejected(info, RejectAuthorityNotActive, prev.FullDigest, chk) + assert.Equal(t, AuthorityPublisher, info.Authority, "the publisher bundle keeps serving") + }) +} + +// The rollback half of the Independent Test: embedded restored, override +// logged, watermark unchanged, the rolled-away release is not re-activated. +func TestRuntime_US1_ExplicitRollbackToEmbedded(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + rel := signedCandidate(t, keyPublisher, bundleOpts{Seq: 10}) + f.activate(rel) + + info, err := RollbackBundle(BundleRollbackRequest{Embedded: true, Digest: embeddedDigest()}, f.log) + require.NoError(t, err) + + assert.Equal(t, BundleSourceEmbedded, info.Source) + assert.Equal(t, embeddedDigest(), info.FullDigest) + assert.True(t, info.Rollback) + assert.True(t, info.Pinned) + st := f.store.persisted(t) + assert.Equal(t, uint64(10), st.Authorities[AuthorityPublisher].Watermark, "the high-watermark is unchanged") + assert.Nil(t, st.Active) + assert.Contains(t, f.logMessages(zapcore.WarnLevel), "TPA scanner bundle ROLLBACK: operator override") + + // The next refresh finds the rejected release at the path and refuses it. + info = f.run() + assert.Equal(t, BundleSourceEmbedded, info.Source) + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectDenyListed, info.LastRejection.Reason) + assert.Equal(t, embeddedDigest(), info.FullDigest) + assert.True(t, info.CoverageDegraded, "the embedded default sits below the retained publisher watermark") + assert.Equal(t, DegradedEmbeddedBelowWatermark, info.DegradedReason) +} + +// --- SC-001: tamper classes at the pipeline level -------------------------------- + +func TestRuntime_SC001_TamperClassesNeverCompile(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + prev := f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 10, Tag: "base"})) + chk := servingCheck() + + good := signedCandidate(t, keyPublisher, bundleOpts{Seq: 11, Tag: "bbb"}) + other := buildBundle(t, bundleOpts{Seq: 12, KeyID: keyPublisher.id}) + renamed := signedCandidate(t, keyPublisher, bundleOpts{Seq: 11, KeyID: keyOperator.id}) + + cases := []struct { + name string + cand Candidate + reason string + }{ + {"flipped byte", Candidate{Bundle: flipByte(t, good.Bundle, "bundle-marker"), Sidecar: good.Sidecar, SidecarPresent: true}, RejectTamper}, + {"signature over a different bundle", Candidate{Bundle: good.Bundle, Sidecar: signBundle(t, keyPublisher.priv, other), SidecarPresent: true}, RejectTamper}, + {"signature from an untrusted key", Candidate{Bundle: good.Bundle, Sidecar: signBundle(t, keyUntrusted.priv, good.Bundle), SidecarPresent: true}, RejectKeyNotTrusted}, + {"manifest names another key", renamed, RejectTamper}, + {"garbage sidecar never falls back to unsigned", Candidate{Bundle: good.Bundle, Sidecar: []byte("not json"), SidecarPresent: true}, RejectSidecarInvalid}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + n := countCompiles(t) + f.put(tc.cand) + info := f.run() + f.requireRejected(info, tc.reason, prev.FullDigest, chk) + assert.Zero(t, *n, "no pattern may be compiled for a refused candidate") + }) + } + + t.Run("the untampered original activates", func(t *testing.T) { + info := f.activate(good) + assert.Equal(t, "11", info.Sequence) + require.NotNil(t, info.LastRejection, "the last rejection survives a later success") + }) +} + +// FR-006 at the pipeline level: each run opens the bundle and the sidecar once. +func TestRuntime_ReadsEachFileOnce(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + var mu sync.Mutex + opens := map[string]int{} + candidateOpenHook = func(p string) { + mu.Lock() + opens[p]++ + mu.Unlock() + } + t.Cleanup(func() { candidateOpenHook = nil }) + + f.put(signedCandidate(t, keyPublisher, bundleOpts{Seq: 3})) + f.run() + + mu.Lock() + defer mu.Unlock() + assert.Equal(t, 1, opens[f.path], "the bundle is read exactly once") + assert.Equal(t, 1, opens[f.path+".sig"], "the sidecar is read exactly once") +} + +// --- SC-010a: ceilings, both sides, at the pipeline level ------------------------- + +func TestRuntime_SC010a_Ceilings(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + prev := f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 1, Tag: "base"})) + chk := servingCheck() + + t.Run("bundle exactly 8 MiB activates, one byte over is refused", func(t *testing.T) { + at := signedCandidate(t, keyPublisher, bundleOpts{Seq: 2, PadTo: MaxBundleBytes}) + require.Len(t, at.Bundle, MaxBundleBytes) + info := f.activate(at) + assert.Equal(t, "2", info.Sequence) + prev, chk = info, servingCheck() + + over := signedCandidate(t, keyPublisher, bundleOpts{Seq: 3, PadTo: MaxBundleBytes + 1}) + require.Len(t, over.Bundle, MaxBundleBytes+1) + f.put(over) + info = f.run() + f.requireRejected(info, RejectCeilingExceeded, prev.FullDigest, chk) + assert.Contains(t, info.LastRejection.Detail, "bundle") + }) + + t.Run("2000 rules activate, 2001 are refused", func(t *testing.T) { + at := signedCandidate(t, keyPublisher, bundleOpts{Seq: 4, Rules: MaxBundleRules}) + info := f.activate(at) + prev, chk = info, servingCheck() + + over := signedCandidate(t, keyPublisher, bundleOpts{Seq: 5, Rules: MaxBundleRules + 1}) + n := countCompiles(t) + f.put(over) + info = f.run() + f.requireRejected(info, RejectCeilingExceeded, prev.FullDigest, chk) + assert.Equal(t, "rules: 2001 > 2000", info.LastRejection.Detail) + assert.Zero(t, *n, "the rule ceiling is enforced before anything is compiled") + }) + + t.Run("sidecar 4096 bytes activates, 4097 is refused", func(t *testing.T) { + c := signedCandidate(t, keyPublisher, bundleOpts{Seq: 6}) + pad := func(n int) []byte { + return append(append([]byte(nil), c.Sidecar...), bytes.Repeat([]byte(" "), n-len(c.Sidecar))...) + } + at := c + at.Sidecar = pad(MaxSidecarBytes) + info := f.activate(at) + prev, chk = info, servingCheck() + + over := signedCandidate(t, keyPublisher, bundleOpts{Seq: 7}) + over.Sidecar = append(append([]byte(nil), over.Sidecar...), bytes.Repeat([]byte(" "), MaxSidecarBytes+1-len(over.Sidecar))...) + f.put(over) + info = f.run() + f.requireRejected(info, RejectCeilingExceeded, prev.FullDigest, chk) + assert.Contains(t, info.LastRejection.Detail, "sidecar") + }) + _ = prev + _ = chk +} + +// stallReader blocks every Read until release is closed, like a read on a +// stalled network filesystem. +type stallReader struct{ release <-chan struct{} } + +func (b stallReader) Read([]byte) (int, error) { + <-b.release + return 0, io.EOF +} + +func TestRuntime_SC010a_ReadBudget(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + prev := f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 1, Tag: "base"})) + chk := servingCheck() + + prevBudget := bundleReadBudget + bundleReadBudget = 50 * time.Millisecond + t.Cleanup(func() { bundleReadBudget = prevBudget }) + + release := make(chan struct{}) + var blocking atomic.Bool + blocking.Store(true) + candidateReaderWrap = func(r io.Reader) io.Reader { + if blocking.Load() { + return stallReader{release: release} + } + return r + } + t.Cleanup(func() { + candidateReaderWrap = nil + select { + case <-release: + default: + close(release) + } + }) + + f.put(signedCandidate(t, keyPublisher, bundleOpts{Seq: 2})) + start := time.Now() + info := f.run() + assert.Less(t, time.Since(start), 5*time.Second, "the run returns within the budget, it does not wait for the reader") + f.requireRejected(info, RejectReadTimeout, prev.FullDigest, chk) + + // A second refresh while the first reader is still parked is refused fast. + start = time.Now() + info = f.run() + assert.Less(t, time.Since(start), 2*time.Second) + f.requireRejected(info, RejectReadTimeout, prev.FullDigest, chk) + + // Once the stalled read returns the path is usable again and a legitimate + // drop activates. + blocking.Store(false) + close(release) + require.Eventually(t, func() bool { + candidateParked.mu.Lock() + defer candidateParked.mu.Unlock() + return len(candidateParked.paths) == 0 + }, 5*time.Second, 10*time.Millisecond, "the parked reader must drain") + bundleReadBudget = prevBudget + info = f.run() + assert.Equal(t, "2", info.Sequence) + assert.Empty(t, info.LoadError) +} + +// --- idempotence ------------------------------------------------------------------ + +func TestRuntime_IdenticalRunsAreIdempotent(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + f.put(signedCandidate(t, keyPublisher, bundleOpts{Seq: 5})) + + first := f.run() + second := f.run() + third := f.run() + + assert.Equal(t, first, second) + assert.Equal(t, first, third) + assert.Equal(t, 1, f.store.commitCount(), "identical runs commit once") + assert.Len(t, f.store.historyRows(), 1, "and append one history row") + loaded := 0 + for _, m := range f.logMessages(zapcore.InfoLevel) { + if m == "loaded TPA scanner bundle" { + loaded++ + } + } + assert.Equal(t, 1, loaded, "and log once") +} + +func TestRuntime_RepeatedRefusalIsLoggedAndRecordedOnce(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 10})) + f.put(signedCandidate(t, keyPublisher, bundleOpts{Seq: 9})) + + first := f.run() + require.NotNil(t, first.LastRejection) + time.Sleep(5 * time.Millisecond) + second := f.run() + + assert.Equal(t, first.LastRejection, second.LastRejection, "the same refusal is not re-recorded") + errs := 0 + for _, m := range f.logMessages(zapcore.ErrorLevel) { + if m == "configured TPA scanner bundle refused; keeping the last-known-good corpus" { + errs++ + } + } + assert.Equal(t, 1, errs, "and not re-logged") +} + +func TestRuntime_ReloadPicksUpAChangedFileWithoutNewConfig(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 5})) + f.put(signedCandidate(t, keyPublisher, bundleOpts{Seq: 6, Tag: "next"})) + + info := ReloadBundle(f.log) + + assert.Equal(t, "6", info.Sequence) + assert.Len(t, f.store.historyRows(), 2) +} + +// --- FR-004b: the active-root declaration governs the SERVING bundle ------------------- + +func TestRuntime_FR004b_RootSwitchDeactivatesServingBundle(t *testing.T) { + f := newRTFixture(t, rtOpts{Sec: func(s *config.SecurityConfig) { + s.TPAActiveRoot = config.TPARootOperator + s.TPAOperatorBaseline = u64p(1) + }}) + op := signedCandidate(t, keyOperator, bundleOpts{Seq: 5}) + info := f.activate(op) + assert.Equal(t, AuthorityOperator, info.Authority) + assert.False(t, info.CoverageDegraded, "a baselined operator bundle under the operator root is full coverage") + + // The operator declares the publisher root; the bundle path is untouched. + f.sec.TPAActiveRoot = config.TPARootPublisher + info = f.run() + + assert.Equal(t, BundleSourceEmbedded, info.Source, "the now-inactive authority's bundle stops serving") + assert.Equal(t, embeddedDigest(), info.FullDigest) + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectAuthorityNotActive, info.LastRejection.Reason) + assert.False(t, info.CoverageDegraded, "an embedded default under the publisher root is full coverage") + assert.Nil(t, f.store.persisted(t).Active) + assert.NotEmpty(t, f.logMessages(zapcore.WarnLevel)) + + // Switching back and removing the file: the operator's bundle is gone. + f.sec.TPAActiveRoot = config.TPARootOperator + f.remove() + info = f.run() + assert.Equal(t, BundleSourceEmbedded, info.Source) + assert.True(t, info.CoverageDegraded) + assert.Equal(t, DegradedOperatorBundleMissing, info.DegradedReason) +} + +func TestRuntime_OperatorWithoutBaselineRunsDegraded(t *testing.T) { + f := newRTFixture(t, rtOpts{Sec: func(s *config.SecurityConfig) { s.TPAActiveRoot = config.TPARootOperator }}) + info := f.activate(signedCandidate(t, keyOperator, bundleOpts{Seq: 5})) + assert.Equal(t, AuthorityOperator, info.Authority) + assert.True(t, info.SignatureVerified) + assert.True(t, info.CoverageDegraded) + assert.Equal(t, DegradedOperatorAuthorityUnbaselined, info.DegradedReason) +} + +// --- state_commit_failed ---------------------------------------------------------------- + +func TestRuntime_CommitFailureKeepsPreviousBundleServing(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + prev := f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 5})) + chk := servingCheck() + + f.store.failCommit = errors.New("disk full") + next := signedCandidate(t, keyPublisher, bundleOpts{Seq: 6, Tag: "next"}) + f.put(next) + info := f.run() + + f.requireRejected(info, RejectStateCommitFailed, prev.FullDigest, chk) + assert.Contains(t, info.LastRejection.Detail, "disk full") + assert.NotContains(t, f.lkgFiles(), digestOf(next.Bundle)+".json", + "the LKG file of a candidate that was not activated is reconciled away") + assert.Equal(t, uint64(5), f.store.persisted(t).Authorities[AuthorityPublisher].Watermark) + + // The store recovers; the very next run activates the candidate. + f.store.failCommit = nil + info = f.run() + assert.Equal(t, "6", info.Sequence) + assert.Empty(t, info.LoadError) +} + +func TestRuntime_CommitFailureOnFreshInstallKeepsEmbeddedServing(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + f.store.failCommit = errors.New("read-only database") + f.put(signedCandidate(t, keyPublisher, bundleOpts{Seq: 5})) + + info := f.run() + + assert.Equal(t, BundleSourceEmbedded, info.Source) + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectStateCommitFailed, info.LastRejection.Reason) + assert.False(t, info.CoverageDegraded) +} + +func TestRuntime_LKGWriteFailureRefusesActivation(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + // A regular file where the LKG directory must be makes every LKG write fail. + require.NoError(t, writeFileForTest(f.dir+"/tpa-lkg", []byte("not a directory"))) + f.put(signedCandidate(t, keyPublisher, bundleOpts{Seq: 5})) + + info := f.run() + + assert.Equal(t, BundleSourceEmbedded, info.Source, "an activation that cannot be made durable does not happen") + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectStateCommitFailed, info.LastRejection.Reason) + assert.Contains(t, info.LastRejection.Detail, "last-known-good") + assert.Zero(t, f.store.commitCount()) +} + +func TestRuntime_StateLoadFailureStillScansAndWritesNothing(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + f.store.failLoad = errors.New("bolt: timeout") + f.put(signedCandidate(t, keyPublisher, bundleOpts{Seq: 5})) + + info := f.run() + + assert.Equal(t, BundleSourceFile, info.Source, "the out-of-data-dir anchors still hold, so a verified bundle may serve") + assert.Zero(t, f.store.commitCount(), "nothing is recorded against a state that could not be read") + assert.NotEmpty(t, f.logMessages(zapcore.ErrorLevel)) + assert.Empty(t, f.lkgFiles(), "and the LKG store is not reconciled against an unknown state") + + // Once the store recovers the state loads and the activation is recorded. + f.store.failLoad = nil + info = f.run() + assert.Equal(t, "5", info.Sequence) + assert.Equal(t, 1, f.store.commitCount()) +} + +func TestRuntime_NoStoreNoDataDirStillWorks(t *testing.T) { + f := newRTFixture(t, rtOpts{NoStore: true, NoDataDir: true}) + f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 10})) + f.put(signedCandidate(t, keyPublisher, bundleOpts{Seq: 9, Tag: "older"})) + info := f.run() + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectDowngrade, info.LastRejection.Reason, "in-memory state alone enforces the watermark") + assert.Empty(t, f.lkgFiles()) + rows, err := BundleHistory() + require.NoError(t, err) + assert.Len(t, rows, 1) +} + +// ApplySecurityConfig carries the whole trust policy, and the Service exposes +// thin delegates for the REST/CLI layer. +func TestService_BundleDelegates(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + svc, _, _ := newTestService(t) + a := signedCandidate(t, keyPublisher, bundleOpts{Seq: 5, Tag: "A"}) + aPath := f.drop + "/a.json" + f.putAt(aPath, a) + f.put(a) + + svc.ApplySecurityConfig(f.sec) + require.Equal(t, "5", svc.BundleStatus().Sequence, "ApplySecurityConfig runs the signed pipeline") + f.put(signedCandidate(t, keyPublisher, bundleOpts{Seq: 6, Tag: "B"})) + assert.Equal(t, "6", svc.ReloadBundle().Sequence) + + rows, err := svc.BundleHistory() + require.NoError(t, err) + assert.Len(t, rows, 2) + + info, err := svc.RollbackBundle(BundleRollbackRequest{Path: aPath, Digest: digestOf(a.Bundle)}) + require.NoError(t, err) + assert.True(t, info.Pinned) + + info, err = svc.UnpinBundle() + require.NoError(t, err) + assert.False(t, info.Pinned) + + // A nil security block means "embedded, publisher root, nothing configured". + svc.ApplySecurityConfig(nil) + assert.Equal(t, BundleSourceEmbedded, svc.BundleStatus().Source) +} + +func TestBundleStatus_StalenessDays(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + stamp := time.Now().Add(-72*time.Hour - time.Minute).UTC().Format(time.RFC3339) + f.put(signedCandidate(t, keyPublisher, bundleOpts{Seq: 5, GeneratedAt: stamp})) + + info := f.run() + + require.NotNil(t, info.StalenessDays) + assert.Equal(t, 3, *info.StalenessDays) + assert.Nil(t, stalenessDays("", time.Now())) + assert.Nil(t, stalenessDays("not a date", time.Now())) + future := stalenessDays(time.Now().Add(48*time.Hour).UTC().Format(time.RFC3339), time.Now()) + require.NotNil(t, future) + assert.Equal(t, 0, *future) +} + +// An unsigned file without a manifest generated_at falls back to its mtime; a +// SIGNED bundle's freshness comes from its signed manifest only. +func TestRuntime_GeneratedAtFallbackIsUnsignedOnly(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + f.put(unsignedCandidate(t, bundleOpts{Tag: "u"})) + assert.NotEmpty(t, f.run().GeneratedAt, "unsigned: mtime fallback") + + f2 := newRTFixture(t, rtOpts{}) + f2.put(signedCandidate(t, keyPublisher, bundleOpts{Seq: 2})) + assert.Empty(t, f2.run().GeneratedAt, "signed: never the attacker-writable mtime") +} diff --git a/internal/security/scanner/tpa_bundle_source.go b/internal/security/scanner/tpa_bundle_source.go index 500e672a7..ecaeb78fd 100644 --- a/internal/security/scanner/tpa_bundle_source.go +++ b/internal/security/scanner/tpa_bundle_source.go @@ -1,14 +1,12 @@ package scanner import ( - "crypto/sha256" - "encoding/hex" - "fmt" - "os" "sync" "time" "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" ) // Bundle source labels reported by BundleInfo.Source. @@ -104,9 +102,10 @@ type BundleRejection struct { At time.Time `json:"at"` } -// activeBundle is the process-wide corpus in use. It is replaced wholesale by -// ConfigureBundle; readers take a snapshot under RLock so a hot-reload can never -// hand a scan a half-swapped corpus. +// activeBundle is the process-wide corpus in use. The serialized runtime +// (tpa_bundle_runtime.go) replaces it wholesale at the end of each run; readers +// take a snapshot under RLock so a hot-reload can never hand a scan a +// half-swapped corpus, and (check, info) are always published together. var ( activeBundleMu sync.RWMutex activeBundleCheck *BundleCheck @@ -115,56 +114,18 @@ var ( // ConfigureBundle installs the TPA signature corpus the scanner will run, // reading it from path when non-empty and falling back to the embedded default -// otherwise (spec 086 FR-019: the path MUST NOT be hardcoded). It is safe to -// call repeatedly — the wiring layer calls it at startup and again on every -// config hot-reload, which is what makes the path re-readable at runtime. +// otherwise (spec 086 FR-019: the path MUST NOT be hardcoded). It is a thin +// wrapper over the serialized runtime with an otherwise default policy and NO +// environment override: the path is explicit. Production code configures the +// full trust policy through ConfigureBundleFromConfig. // // Failure policy (fail-closed, never fail-empty): if the configured file cannot -// be read, parsed, version-checked, or compiled, the currently active corpus -// (or the embedded default on first call) stays live and the reason is recorded -// in BundleInfo.LoadError AND logged through the INJECTED logger. The previous -// implementation logged through the unconfigured global zap.L(), so its one -// status report reached no console and no log file (GH #938 finding 2). +// be read, verified, parsed, version-checked, or compiled, the currently active +// corpus (or the embedded default on first call) stays live and the reason is +// recorded in BundleInfo.LoadError and BundleInfo.LastRejection AND logged +// through the INJECTED logger. func ConfigureBundle(path string, logger *zap.Logger) { - if logger == nil { - logger = zap.NewNop() - } - log := logger.Named("security.scanner") - - if path == "" { - check, info, err := loadEmbeddedBundle() - if err != nil { - log.Warn("embedded TPA scanner bundle failed to load; continuing without bundle-backed checks", - zap.Error(err)) - storeBundle(nil, BundleInfo{Source: BundleSourceEmbedded, LoadedAt: time.Now(), LoadError: err.Error()}) - return - } - storeBundle(check, info) - logBundle(log, info) - return - } - - check, info, err := loadBundleFromFile(path) - if err != nil { - // Keep the last-known-good corpus (or install the embedded default if - // this is the first configuration) and make the failure visible. - prevCheck, prevInfo := snapshotBundle() - if prevCheck == nil { - if embeddedCheck, embeddedInfo, embErr := loadEmbeddedBundle(); embErr == nil { - prevCheck, prevInfo = embeddedCheck, embeddedInfo - } - } - prevInfo.LoadError = err.Error() - storeBundle(prevCheck, prevInfo) - log.Error("configured TPA scanner bundle failed to load; keeping the previously active corpus", - zap.String("path", path), - zap.String("active_source", prevInfo.Source), - zap.Int("runnable_rules", prevInfo.RunnableRules), - zap.Error(err)) - return - } - storeBundle(check, info) - logBundle(log, info) + configureWithPolicy(policyFor(&config.SecurityConfig{TPABundlePath: path}, path), logger) } // BundleStatus returns a snapshot of the active corpus for the operator-facing @@ -172,14 +133,42 @@ func ConfigureBundle(path string, logger *zap.Logger) { // It lazily installs the embedded default so a caller that never configured a // bundle still gets a truthful answer instead of a zero value. func BundleStatus() BundleInfo { - if check, info := snapshotBundle(); check != nil || info.LoadError != "" { - return info - } - ConfigureBundle("", zap.NewNop()) + _, info := activeBundleSnapshot() + info.StalenessDays = stalenessDays(info.GeneratedAt, time.Now()) + return info +} + +// statusFromSnapshot is BundleStatus without the lazy install, for callers that +// already hold bundleRT.mu (the lazy install would deadlock on it). +func statusFromSnapshot() BundleInfo { _, info := snapshotBundle() + info.StalenessDays = stalenessDays(info.GeneratedAt, time.Now()) return info } +// stalenessDays is the whole days between generatedAt and now, nil when the +// stamp is absent or unparseable. A stamp in the future reads as 0. +func stalenessDays(generatedAt string, now time.Time) *int { + if generatedAt == "" { + return nil + } + var t time.Time + var err error + for _, layout := range []string{time.RFC3339, "2006-01-02"} { + if t, err = time.Parse(layout, generatedAt); err == nil { + break + } + } + if err != nil { + return nil + } + days := int(now.Sub(t).Hours() / 24) + if days < 0 { + days = 0 + } + return &days +} + // logBundle emits the one status report an operator can grep for. func logBundle(log *zap.Logger, info BundleInfo) { log.Info("loaded TPA scanner bundle", @@ -188,6 +177,15 @@ func logBundle(log *zap.Logger, info BundleInfo) { zap.String("bundle_version", info.BundleVersion), zap.String("generated_at", info.GeneratedAt), zap.String("fingerprint", info.Fingerprint), + zap.String("full_digest", info.FullDigest), + zap.String("sequence", info.Sequence), + zap.String("authority", info.Authority), + zap.Uint64("trust_epoch", info.TrustEpoch), + zap.Bool("signature_verified", info.SignatureVerified), + zap.Bool("coverage_degraded", info.CoverageDegraded), + zap.String("degraded_reason", info.DegradedReason), + zap.Bool("pinned", info.Pinned), + zap.Bool("rollback", info.Rollback), zap.Int("signature_count", info.SignatureCount), zap.Int("runnable_rules", info.RunnableRules), zap.Int("skipped_rules", info.SkippedRules), @@ -207,58 +205,28 @@ func snapshotBundle() (*BundleCheck, BundleInfo) { return activeBundleCheck, activeBundleInfo } -// loadEmbeddedBundle compiles the corpus shipped with this build. -func loadEmbeddedBundle() (*BundleCheck, BundleInfo, error) { - check, info, err := loadBundleWithInfo(bundledScannerBundle) - if err != nil { - return nil, BundleInfo{}, err - } - info.Source = BundleSourceEmbedded - return check, info, nil -} - -// loadBundleFromFile reads and compiles a corpus from the configured path. A -// missing file is an error (not a silent fallback) so a mistyped path is -// reported rather than looking like a working configuration. -func loadBundleFromFile(path string) (*BundleCheck, BundleInfo, error) { - data, err := os.ReadFile(path) //nolint:gosec // operator-configured path, same trust level as mcp_config.json - if err != nil { - return nil, BundleInfo{}, fmt.Errorf("read scanner bundle %s: %w", path, err) +// activeBundleSnapshot returns ONE consistent (check, info) pair, lazily +// installing the embedded default when nothing has been published yet. Callers +// that need both the check and its coverage (the scan gate) must take them from +// this single snapshot, never from two lock acquisitions. +func activeBundleSnapshot() (*BundleCheck, BundleInfo) { + check, info := snapshotBundle() + if check != nil || info.LoadError != "" { + return check, info } - check, info, err := loadBundleWithInfo(data) - if err != nil { - return nil, BundleInfo{}, fmt.Errorf("scanner bundle %s: %w", path, err) - } - info.Source = BundleSourceFile - info.Path = path - if info.GeneratedAt == "" { - // The v0.1.0 bundle format carries no generated_at; the file's mtime is - // the next-best freshness signal and is strictly better than nothing. - if st, statErr := os.Stat(path); statErr == nil { - info.GeneratedAt = st.ModTime().UTC().Format(time.RFC3339) - } - } - return check, info, nil + installDefaultBundleIfUnpublished() + return snapshotBundle() } -// loadBundleWithInfo compiles raw bundle bytes and derives the operator-facing -// metadata alongside the check. -func loadBundleWithInfo(data []byte) (*BundleCheck, BundleInfo, error) { - check, stats, err := loadBundleCheck(data) - if err != nil { - return nil, BundleInfo{}, err +// installDefaultBundleIfUnpublished installs the embedded default, but only if +// nothing has been published by the time the activation mutex is held: a real +// configuration that raced this lazy install must never be clobbered by the +// default policy. +func installDefaultBundleIfUnpublished() { + bundleRT.mu.Lock() + defer bundleRT.mu.Unlock() + if check, info := snapshotBundle(); check != nil || info.LoadError != "" { + return } - meta := bundleMetadata(data) - sum := sha256.Sum256(data) - return check, BundleInfo{ - BundleVersion: meta.BundleVersion, - SchemaVersion: meta.SchemaVersion, - SignatureCount: meta.SignatureCount, - GeneratedAt: meta.GeneratedAt, - RunnableRules: stats.Runnable, - SkippedRules: stats.Skipped, - DeclaredSkipped: stats.Declared, - Fingerprint: hex.EncodeToString(sum[:])[:12], - LoadedAt: time.Now(), - }, nil + bundleRT.runLocked(bundleRT.policyOrDefaultLocked(), bundleLogger(nil), runOptions{force: true}) } diff --git a/internal/security/scanner/tpa_bundle_source_test.go b/internal/security/scanner/tpa_bundle_source_test.go index e87e5012a..657a3b96f 100644 --- a/internal/security/scanner/tpa_bundle_source_test.go +++ b/internal/security/scanner/tpa_bundle_source_test.go @@ -42,7 +42,9 @@ func writeBundle(t *testing.T, content string) string { // file-sourced corpus never leaks into the rest of the package. func restoreEmbeddedBundle(t *testing.T) { t.Helper() - t.Cleanup(func() { ConfigureBundle("", zap.NewNop()) }) + // The loader is stateful (idempotence, anti-downgrade state), so each test + // starts from a fresh runtime and restores the embedded default on cleanup. + resetBundleRuntimeForTest(t) } // TestBundleStatus_DefaultsToEmbedded is the visibility half of GH #938 finding diff --git a/internal/security/scanner/tpa_bundle_substitution_test.go b/internal/security/scanner/tpa_bundle_substitution_test.go new file mode 100644 index 000000000..f97bba59b --- /dev/null +++ b/internal/security/scanner/tpa_bundle_substitution_test.go @@ -0,0 +1,84 @@ +package scanner + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// SC-012: an attacker who can replace config.db hands the loader a state with a +// lowered watermark, a cleared ratchet and a forged pin. The guarantees that +// must survive are exactly the ones anchored OUTSIDE the data directory: the +// publisher baseline (from the binary), the operator baseline, require_signed +// and the active-root declaration. This test asserts only those; it deliberately +// asserts nothing about the ratchet, the watermark or the pin surviving. +func TestSC012_StateSubstitution_OutOfDataDirAnchorsStillHold(t *testing.T) { + forged := func(pinDigest string, pinSeq uint64) *TPABundleState { + return &TPABundleState{ + SchemaVersion: TPABundleStateSchemaVersion, + SignatureRatchet: false, // cleared + Authorities: map[string]*TPAAuthorityState{ + AuthorityPublisher: {Epoch: 0, Watermark: 0, Pin: &TPABundlePin{Sequence: pinSeq, FullDigest: pinDigest, PinnedAt: time.Now()}}, + AuthorityOperator: {Epoch: 0, Watermark: 0, Pin: &TPABundlePin{Sequence: pinSeq, FullDigest: pinDigest, PinnedAt: time.Now()}}, + }, + } + } + + t.Run("publisher baseline refuses an older publisher bundle, even one a forged pin names", func(t *testing.T) { + f := newRTFixture(t, rtOpts{EmbeddedSeq: 100}) + old := signedCandidate(t, keyPublisher, bundleOpts{Seq: 50, Tag: "old"}) + f.store.loadOverride = forged(digestOf(old.Bundle), 50) + f.put(old) + + info := f.run() + + assert.Equal(t, embeddedDigest(), info.FullDigest) + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectDowngrade, info.LastRejection.Reason) + assert.Contains(t, info.LastRejection.Detail, "baseline 100") + }) + + t.Run("require_signed_bundle still refuses an unsigned drop", func(t *testing.T) { + f := newRTFixture(t, rtOpts{Sec: func(s *config.SecurityConfig) { s.RequireSignedBundle = true }}) + f.store.loadOverride = forged("", 0) + f.put(unsignedCandidate(t, bundleOpts{Seq: 5})) + + info := f.run() + + assert.Equal(t, embeddedDigest(), info.FullDigest) + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectSignatureRequired, info.LastRejection.Reason) + }) + + t.Run("operator baseline still refuses an older operator bundle", func(t *testing.T) { + f := newRTFixture(t, rtOpts{Sec: func(s *config.SecurityConfig) { + s.TPAActiveRoot = config.TPARootOperator + s.TPAOperatorBaseline = u64p(10) + }}) + old := signedCandidate(t, keyOperator, bundleOpts{Seq: 5, Tag: "old"}) + f.store.loadOverride = forged(digestOf(old.Bundle), 5) + f.put(old) + + info := f.run() + + assert.Equal(t, embeddedDigest(), info.FullDigest) + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectDowngrade, info.LastRejection.Reason) + }) + + t.Run("the root declaration still refuses the other authority", func(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + f.store.loadOverride = forged("", 0) + f.put(signedCandidate(t, keyOperator, bundleOpts{Seq: 50})) + + info := f.run() + + assert.Equal(t, embeddedDigest(), info.FullDigest) + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectAuthorityNotActive, info.LastRejection.Reason) + }) +} diff --git a/internal/security/scanner/tpa_candidate.go b/internal/security/scanner/tpa_candidate.go index ea995a045..008a1f94c 100644 --- a/internal/security/scanner/tpa_candidate.go +++ b/internal/security/scanner/tpa_candidate.go @@ -54,6 +54,14 @@ type evalEnv struct { RollbackDigest string // Now stamps rejections; nil means time.Now. Now func() time.Time + // Reuse, when non-nil and for the very same bytes (equal FullDigest), lets + // the tail of the pipeline reuse the already-compiled check: the compile + // stage and the activation self-check are skipped because this exact + // bundle already passed both when it activated. The runtime sets it only + // when re-validating the serving bundle against a new policy or state; + // every policy- and state-dependent stage (signature, authority, epoch, + // deny-list, watermark) still runs in full. + Reuse *acceptedBundle } // acceptedBundle is a candidate that passed every stage. @@ -144,6 +152,9 @@ func (ev *evaluation) parseAndCeil() (*rawBundle, *BundleRejection) { // compatibility, compile-all-or-reject, runnable-rules > 0, then the // activation self-check. func (ev *evaluation) compileAndGate(m *rawBundle) (*BundleCheck, bundleLoadStats, *BundleRejection) { + if r := ev.env.Reuse; r != nil && r.Check != nil && r.FullDigest == ev.digest { + return r.Check, r.Stats, nil + } check, stats, err := compileBundle(m) if err != nil { var ce *bundleCeilingError diff --git a/internal/security/scanner/tpa_runtime_helpers_test.go b/internal/security/scanner/tpa_runtime_helpers_test.go new file mode 100644 index 000000000..fb9bf5d5f --- /dev/null +++ b/internal/security/scanner/tpa_runtime_helpers_test.go @@ -0,0 +1,293 @@ +package scanner + +import ( + "crypto/ed25519" + "encoding/json" + "errors" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// fakeBundleStore is an in-memory TPABundleStateStore with fault injection. It +// round-trips every value through JSON, exactly like the bbolt-backed +// implementation, so a runtime that kept a pointer into "persisted" state would +// be caught by the tests rather than by an attacker. +type fakeBundleStore struct { + mu sync.Mutex + state []byte // nil => never written + history []TPABundleActivation + + commits int + loads int + + // failCommit, when non-nil, is returned by every CommitTPABundleState and + // nothing is written. + failCommit error + // failLoad, when non-nil, is returned by every LoadTPABundleState. + failLoad error + // loadOverride, when non-nil, is returned INSTEAD of the stored state: the + // SC-012 "attacker substituted config.db" lever. + loadOverride *TPABundleState +} + +func (s *fakeBundleStore) LoadTPABundleState() (*TPABundleState, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.loads++ + if s.failLoad != nil { + return nil, s.failLoad + } + if s.loadOverride != nil { + return cloneState(s.loadOverride), nil + } + if s.state == nil { + return nil, nil + } + var st TPABundleState + if err := json.Unmarshal(s.state, &st); err != nil { + return nil, err + } + return &st, nil +} + +func (s *fakeBundleStore) CommitTPABundleState(state *TPABundleState, entry *TPABundleActivation) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.failCommit != nil { + return s.failCommit + } + raw, err := json.Marshal(state) + if err != nil { + return err + } + s.state = raw + if entry != nil { + s.history = append(s.history, *entry) + } + s.commits++ + return nil +} + +func (s *fakeBundleStore) ListTPABundleHistory() ([]TPABundleActivation, error) { + s.mu.Lock() + defer s.mu.Unlock() + return append([]TPABundleActivation(nil), s.history...), nil +} + +// persisted returns the committed state (nil when never written). +func (s *fakeBundleStore) persisted(t *testing.T) *TPABundleState { + t.Helper() + s.mu.Lock() + defer s.mu.Unlock() + if s.state == nil { + return nil + } + var st TPABundleState + require.NoError(t, json.Unmarshal(s.state, &st)) + return &st +} + +func (s *fakeBundleStore) commitCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.commits +} + +func (s *fakeBundleStore) historyRows() []TPABundleActivation { + rows, _ := s.ListTPABundleHistory() + return rows +} + +// clearBundleRuntime wipes every piece of runtime state and the published +// snapshot, leaving the package as a fresh process would find it. +func clearBundleRuntime() { + bundleRT.mu.Lock() + bundleRT.resetLocked() + bundleRT.mu.Unlock() + storeBundle(nil, BundleInfo{}) + lkgAfterWriteHook = nil + stateAfterCommitHook = nil + bundleSelfCheck = nil + bundleCompileHook = nil +} + +// resetBundleRuntimeForTest clears the runtime now and again on cleanup, +// restoring the embedded default so no state leaks into the rest of the package. +func resetBundleRuntimeForTest(t *testing.T) { + t.Helper() + clearBundleRuntime() + t.Cleanup(func() { + clearBundleRuntime() + ConfigureBundle("", zap.NewNop()) + }) +} + +// rtOpts parametrizes newRTFixture. +type rtOpts struct { + // EmbeddedSeq is the sequence stamped into the embedded default (0 = none), + // i.e. the publisher baseline. + EmbeddedSeq uint64 + // Sec mutates the SecurityConfig after the defaults are applied. + Sec func(*config.SecurityConfig) + // NoStore runs the runtime with no state store (in-memory state only). + NoStore bool + // NoDataDir runs the runtime with no LKG directory. + NoDataDir bool +} + +// rtFixture is one runtime: a fake store, a data dir, a drop directory holding +// the configured bundle path, and an observed logger. +type rtFixture struct { + t *testing.T + store *fakeBundleStore + dir string // data dir (tpa-lkg lives under it) + drop string // directory of the configured bundle path + path string // configured bundle path + sec *config.SecurityConfig + logs *observer.ObservedLogs + log *zap.Logger +} + +func newRTFixture(t *testing.T, o rtOpts) *rtFixture { + t.Helper() + t.Setenv(config.EnvTPABundlePath, "") + resetBundleRuntimeForTest(t) + withTrustAnchors(t, []ed25519.PublicKey{keyPublisher.pub}, 0, + buildBundle(t, bundleOpts{Seq: o.EmbeddedSeq, Tag: "embedded"})) + + core, logs := observer.New(zapcore.DebugLevel) + f := &rtFixture{ + t: t, + store: &fakeBundleStore{}, + dir: t.TempDir(), + drop: t.TempDir(), + logs: logs, + log: zap.New(core), + } + f.path = filepath.Join(f.drop, "scanner-bundle.json") + f.sec = &config.SecurityConfig{TPABundlePath: f.path, TPAOperatorSigners: []string{keyOperator.b64}} + if o.Sec != nil { + o.Sec(f.sec) + } + var store TPABundleStateStore + if !o.NoStore { + store = f.store + } + dir := f.dir + if o.NoDataDir { + dir = "" + } + SetBundleRuntime(store, dir) + return f +} + +// put writes the candidate to the configured path (and its sidecar, or removes +// a stale one). +func (f *rtFixture) put(c Candidate) { f.putAt(f.path, c) } + +func (f *rtFixture) putAt(path string, c Candidate) { + f.t.Helper() + require.NoError(f.t, os.WriteFile(path, c.Bundle, 0o600)) + if c.SidecarPresent { + require.NoError(f.t, os.WriteFile(path+".sig", c.Sidecar, 0o600)) + return + } + if err := os.Remove(path + ".sig"); err != nil && !errors.Is(err, os.ErrNotExist) { + f.t.Fatal(err) + } +} + +// remove deletes the configured bundle file and its sidecar. +func (f *rtFixture) remove() { + f.t.Helper() + for _, p := range []string{f.path, f.path + ".sig"} { + if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) { + f.t.Fatal(err) + } + } +} + +// run applies the fixture's config through the production entry point and +// returns the published status. +func (f *rtFixture) run() BundleInfo { + f.t.Helper() + ConfigureBundleFromConfig(f.sec, f.log) + return BundleStatus() +} + +// restart simulates a process restart over the same store and data dir: every +// in-memory piece is dropped, the persisted state and the LKG files remain. +func (f *rtFixture) restart() { + f.t.Helper() + clearBundleRuntime() + SetBundleRuntime(f.store, f.dir) +} + +func (f *rtFixture) lkgFiles() []string { + entries, err := os.ReadDir(filepath.Join(f.dir, "tpa-lkg")) + if errors.Is(err, os.ErrNotExist) { + return nil + } + require.NoError(f.t, err) + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + return names +} + +func (f *rtFixture) logMessages(level zapcore.Level) []string { + var out []string + for _, e := range f.logs.All() { + if e.Level == level { + out = append(out, e.Message) + } + } + return out +} + +// servingCheck is the identity of the published check (pointer), for "the +// last-known-good kept serving" assertions. +func servingCheck() *BundleCheck { + check, _ := snapshotBundle() + return check +} + +// requireRejected asserts the last run was refused with reason while the +// previous serving bundle (identified by digest and check pointer) kept +// serving. +func (f *rtFixture) requireRejected(info BundleInfo, reason string, prevDigest string, prevCheck *BundleCheck) { + f.t.Helper() + require.NotNil(f.t, info.LastRejection, "a rejection must be recorded") + require.Equal(f.t, reason, info.LastRejection.Reason, "detail: %s", info.LastRejection.Detail) + require.False(f.t, info.LastRejection.At.IsZero()) + require.NotEmpty(f.t, info.LoadError) + require.Contains(f.t, info.LoadError, reason) + require.Equal(f.t, prevDigest, info.FullDigest, "last-known-good must keep serving") + require.Same(f.t, prevCheck, servingCheck(), "the published check must be the last-known-good check") +} + +// activate drops c, runs the pipeline and requires c to be the serving bundle. +func (f *rtFixture) activate(c Candidate) BundleInfo { + f.t.Helper() + f.put(c) + info := f.run() + require.Equal(f.t, digestOf(c.Bundle), info.FullDigest, "candidate must activate; load_error=%q", info.LoadError) + require.Empty(f.t, info.LoadError) + return info +} + +// embeddedDigest is the full digest of the embedded default currently in force. +func embeddedDigest() string { return digestOf(bundledScannerBundle) } + +// writeFileForTest writes data to path with owner-only permissions. +func writeFileForTest(path string, data []byte) error { return os.WriteFile(path, data, 0o600) } From 34be6d12078fb992ad6298a3294e75088878890c Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 14:09:00 +0300 Subject: [PATCH 11/35] feat(security): Spec 101 US1 Web UI bundle status panel and degraded banner Show sequence, authority, trust epoch, signature verified, age, the short fingerprint with the full digest on demand, a role=alert banner per degraded_reason (auto-approval suspended, changes go to review), and the last rejection. No banner when coverage is not degraded. Related #938 --- frontend/src/utils/signatureBundle.ts | 136 ++++++++++++++++++ frontend/src/views/Security.vue | 63 ++++++++ .../unit/security-bundle-status-panel.spec.ts | 120 ++++++++++++++++ .../unit/signature-bundle-spec101.spec.ts | 136 ++++++++++++++++++ 4 files changed, 455 insertions(+) create mode 100644 frontend/tests/unit/security-bundle-status-panel.spec.ts create mode 100644 frontend/tests/unit/signature-bundle-spec101.spec.ts diff --git a/frontend/src/utils/signatureBundle.ts b/frontend/src/utils/signatureBundle.ts index b1bb1daec..b833bd2fd 100644 --- a/frontend/src/utils/signatureBundle.ts +++ b/frontend/src/utils/signatureBundle.ts @@ -22,6 +22,42 @@ export interface SignatureBundle { fingerprint?: string loaded_at?: string load_error?: string + // --- Spec 101 FR-020 (contracts/bundle-status-surface.md) --- + full_digest?: string + sequence?: string + authority?: string + trust_epoch?: number + signature_verified?: boolean + staleness_days?: number + coverage_degraded?: boolean + degraded_reason?: string + pinned?: boolean + rollback?: boolean + last_rejection?: BundleRejection | null +} + +export interface BundleRejection { + reason: string + detail?: string + at?: string +} + +export interface IdentityRow { + label: string + value: string +} + +export interface DegradedCoverage { + reason: string + /** Plain-language why, ending with what happens to scan-mode auto-approval. */ + explanation: string +} + +export interface LastRejectionSummary { + reason: string + label: string + detail: string + at: string } export interface SignatureBundleSummary { @@ -35,6 +71,71 @@ export interface SignatureBundleSummary { tooltip: string /** Tailwind/DaisyUI tone class; empty means default. */ tone: string + /** 12-hex human label ('' when unknown). */ + shortFingerprint: string + /** Complete SHA-256 ('' when the daemon does not report it) — shown on demand. */ + fullDigest: string + /** Sequence / authority / epoch / signature / age rows, only those reported. */ + identity: IdentityRow[] + /** Non-null ONLY when coverage is degraded: the one state that needs action. */ + degraded: DegradedCoverage | null + /** Most recent refused candidate; last-known-good is still serving. */ + lastRejection: LastRejectionSummary | null + pinned: boolean + rollback: boolean +} + +const SUSPENDED = + 'Scan-mode auto-approval is suspended, so tool changes go to review until this clears.' + +const DEGRADED_TEXT: Record = { + unsigned_external_active: + 'An unsigned signature bundle (dropped in or fetched) is active, and its origin cannot be verified.', + embedded_below_watermark: + 'The built-in signature set is older than a signed bundle this install has already used, so it may be missing newer signatures.', + operator_bundle_missing: + 'The operator-signed bundle that was previously active is missing or no longer valid, and a fallback set is serving.', + operator_authority_unbaselined: + 'An operator-signed bundle is active, but the operator authority has no baseline configured to check it against.', +} + +/** Plain-language explanation for a degraded_reason; unknown codes stay visible. */ +export function describeDegradedReason(reason: string | undefined): string { + const why = reason && DEGRADED_TEXT[reason] + ? DEGRADED_TEXT[reason] + : reason + ? `Signature coverage is degraded (${reason}).` + : 'Signature coverage is degraded.' + return `${why} ${SUSPENDED}` +} + +const REJECTION_LABELS: Record = { + tamper: 'Tamper: signature did not verify', + downgrade: 'Downgrade refused', + stale_epoch_replay: 'Stale epoch replay refused', + epoch_ahead: 'Bundle newer than this binary trusts (update mcpproxy)', + unsigned_refused_by_ratchet: 'Unsigned bundle refused after a signed one was used', + signature_required: 'Signature required but missing', + authority_not_active: 'Signed by the non-active authority', + rollback_target_mismatch: 'Rollback target digest mismatch', + deny_listed: 'Rolled-away release refused', + gate_failure: 'Activation self-check failed', + ceiling_exceeded: 'Size or rule-count ceiling exceeded', + not_regular_file: 'Not a regular file', + read_timeout: 'Read timed out', + read_error: 'Could not read the bundle', + sidecar_invalid: 'Signature sidecar invalid', + key_not_trusted: 'Signing key not trusted', + invalid_bundle: 'Bundle invalid', + state_commit_failed: 'Could not record anti-downgrade state', +} + +function rejectionLabel(reason: string): string { + return REJECTION_LABELS[reason] ?? reason.replace(/_/g, ' ') +} + +function plural(n: number, unit: string): string { + return `${n} ${unit}${n === 1 ? '' : 's'}` } /** @@ -72,6 +173,10 @@ export function formatSignatureBundle( detail = `${detail} — configured bundle load failed` tooltipParts.push(bundle.load_error) } + // Degraded coverage needs action, so it gets the warning tone (the banner + // carries the why). Zero runnable rules below still outranks it. + const isDegraded = bundle.coverage_degraded === true + if (isDegraded && tone === '') tone = 'text-warning' if (runnable === 0) { // Zero runnable rules means offline TPA coverage is OFF: nothing is being // matched, so a scan verdict carries no signal at all. This outranks the @@ -80,11 +185,42 @@ export function formatSignatureBundle( detail = `${detail} — no signatures running` } + const fullDigest = bundle.full_digest || '' + const shortFingerprint = bundle.fingerprint || fullDigest.slice(0, 12) + + const identity: IdentityRow[] = [] + if (bundle.sequence) identity.push({ label: 'Sequence', value: bundle.sequence }) + if (bundle.authority) identity.push({ label: 'Authority', value: bundle.authority }) + if (typeof bundle.trust_epoch === 'number') { + identity.push({ label: 'Trust epoch', value: String(bundle.trust_epoch) }) + } + identity.push({ + label: 'Signature', + value: bundle.signature_verified ? 'verified' : 'not verified', + }) + if (typeof bundle.staleness_days === 'number') { + identity.push({ label: 'Age', value: plural(bundle.staleness_days, 'day') }) + } + + 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 + return { title: 'Signatures (runnable)', value: String(runnable), detail, tooltip: tooltipParts.join(' · '), tone, + shortFingerprint, + fullDigest, + identity, + degraded: isDegraded + ? { reason: bundle.degraded_reason ?? '', explanation: describeDegradedReason(bundle.degraded_reason) } + : null, + lastRejection, + pinned: bundle.pinned === true, + rollback: bundle.rollback === true, } } diff --git a/frontend/src/views/Security.vue b/frontend/src/views/Security.vue index f41f5b34c..08daf51b3 100644 --- a/frontend/src/views/Security.vue +++ b/frontend/src/views/Security.vue @@ -170,6 +170,53 @@ + + + +
+
+ Signature bundle + + {{ row.label }}: {{ row.value }} + + pinned + rolled back +
+
+ Fingerprint + {{ signatureBundle.shortFingerprint }} + +
+
+ {{ signatureBundle.fullDigest }} + +
+
+ Last refused candidate: {{ signatureBundle.lastRejection.label }} + — {{ signatureBundle.lastRejection.detail }} + ({{ signatureBundle.lastRejection.at }}). + The previous bundle is still serving. +
+
+
@@ -617,6 +664,22 @@ const totalFindings = computed(() => overview.value?.findings_by_severity?.total // simply absent rather than rendering a misleading zero. const signatureBundle = computed(() => formatSignatureBundle(overview.value?.signature_bundle)) +// Full digest is the rollback input (Spec 101 FR-010), so it is available in +// full on demand rather than cluttering the default view. +const showFullDigest = ref(false) +const digestCopied = ref(false) +async function copyFullDigest() { + const text = signatureBundle.value?.fullDigest || '' + if (!text) return + try { + await navigator.clipboard.writeText(text) + digestCopied.value = true + setTimeout(() => { digestCopied.value = false }, 1500) + } catch { + // Clipboard blocked: the digest is already visible and selectable. + } +} + // Docker isolation state — populated from /api/v1/config + /api/v1/servers. const isolationEnabled = ref(false) const stdioServerCount = ref(0) diff --git a/frontend/tests/unit/security-bundle-status-panel.spec.ts b/frontend/tests/unit/security-bundle-status-panel.spec.ts new file mode 100644 index 000000000..bc14d3371 --- /dev/null +++ b/frontend/tests/unit/security-bundle-status-panel.spec.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { createRouter, createWebHistory } from 'vue-router' + +// Spec 101 FR-020: the Security page answers "am I current and verified, or +// stuck on last-known-good, and why?". Only a state that needs action (degraded +// coverage) gets an alert; identity is shown with the full digest on demand. + +const FULL = 'c0ffee012345' + 'd'.repeat(52) +let bundle: Record + +vi.mock('@/services/api', () => { + const ok = (data: unknown = {}) => Promise.resolve({ success: true, data }) + return { + default: { + getSecurityOverview: vi.fn(() => + ok({ + scanners_installed: 1, + total_scans: 0, + active_scans: 0, + findings_by_severity: { total: 0, critical: 0, high: 0 }, + docker_available: true, + signature_bundle: bundle, + }), + ), + listScanners: vi.fn(() => ok([])), + listScanHistory: vi.fn(() => ok({ scans: [], total: 0 })), + getQueueProgress: vi.fn(() => ok({ status: 'idle' })), + getConfig: vi.fn(() => ok({ docker_isolation: { enabled: false }, security: { deep_scan: { enabled: false } } })), + getServers: vi.fn(() => ok({ servers: [] })), + patchConfig: vi.fn(() => ok({})), + updateConfig: vi.fn(() => ok({})), + }, + } +}) + +async function mountSecurity() { + const Security = (await import('@/views/Security.vue')).default + const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', component: { template: '
' } }, + { path: '/settings', component: { template: '
' } }, + { path: '/security/scans/:jobId', component: { template: '
' } }, + ], + }) + await router.push('/') + await router.isReady() + const wrapper = mount(Security, { global: { plugins: [createPinia(), router] } }) + await flushPromises() + return wrapper +} + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + bundle = { + source: 'file', + path: '/opt/tpa/b.json', + runnable_rules: 12, + fingerprint: FULL.slice(0, 12), + full_digest: FULL, + sequence: '42', + authority: 'operator', + trust_epoch: 3, + signature_verified: true, + staleness_days: 4, + coverage_degraded: false, + } +}) + +describe('Security bundle status panel (Spec 101)', () => { + it('shows no alert when coverage is healthy, but shows identity', async () => { + const wrapper = await mountSecurity() + expect(wrapper.find('[data-test="bundle-degraded-alert"]').exists()).toBe(false) + const panel = wrapper.get('[data-test="bundle-status-panel"]') + expect(panel.text()).toContain('42') + expect(panel.text()).toContain('operator') + expect(panel.text()).toContain('verified') + expect(panel.text()).toContain(FULL.slice(0, 12)) + }) + + it('keeps the full digest out of the way until asked, then shows it in full', async () => { + const wrapper = await mountSecurity() + expect(wrapper.find('[data-test="bundle-full-digest"]').exists()).toBe(false) + await wrapper.get('[data-test="bundle-digest-toggle"]').trigger('click') + expect(wrapper.get('[data-test="bundle-full-digest"]').text()).toBe(FULL) + }) + + it('raises a role=alert banner naming the reason when degraded', async () => { + bundle = { ...bundle, coverage_degraded: true, degraded_reason: 'unsigned_external_active' } + const wrapper = await mountSecurity() + const alert = wrapper.get('[data-test="bundle-degraded-alert"]') + expect(alert.attributes('role')).toBe('alert') + expect(alert.text().toLowerCase()).toContain('degraded') + expect(alert.text().toLowerCase()).toContain('unsigned') + expect(alert.text().toLowerCase()).toContain('auto-approval') + expect(alert.text().toLowerCase()).toContain('review') + }) + + it('shows the last rejection with reason, detail and time, without an alert', async () => { + bundle = { + ...bundle, + last_rejection: { reason: 'downgrade', detail: 'sequence 3 below watermark 5', at: '2026-10-19T10:00:00Z' }, + } + const wrapper = await mountSecurity() + const rej = wrapper.get('[data-test="bundle-last-rejection"]') + expect(rej.text().toLowerCase()).toContain('downgrade') + expect(rej.text()).toContain('sequence 3 below watermark 5') + expect(rej.text()).toContain('2026') + expect(wrapper.find('[data-test="bundle-degraded-alert"]').exists()).toBe(false) + }) + + it('renders no panel for an older daemon without a bundle descriptor', async () => { + bundle = {} + const wrapper = await mountSecurity() + expect(wrapper.find('[data-test="bundle-status-panel"]').exists()).toBe(false) + }) +}) diff --git a/frontend/tests/unit/signature-bundle-spec101.spec.ts b/frontend/tests/unit/signature-bundle-spec101.spec.ts new file mode 100644 index 000000000..27e0cc443 --- /dev/null +++ b/frontend/tests/unit/signature-bundle-spec101.spec.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest' +import { formatSignatureBundle, describeDegradedReason } from '@/utils/signatureBundle' + +const FULL = 'a'.repeat(12) + 'b'.repeat(52) + +// Spec 101 FR-020 / contracts/bundle-status-surface.md: the status surface must +// answer "am I current and verified, or stuck on last-known-good, and why?". +describe('formatSignatureBundle — Spec 101 identity fields', () => { + it('exposes short fingerprint, full digest, sequence, authority, epoch, verified, staleness', () => { + const out = formatSignatureBundle({ + source: 'file', + path: '/opt/tpa/b.json', + runnable_rules: 12, + fingerprint: FULL.slice(0, 12), + full_digest: FULL, + sequence: '42', + authority: 'operator', + trust_epoch: 3, + signature_verified: true, + staleness_days: 9, + })! + expect(out.shortFingerprint).toBe(FULL.slice(0, 12)) + expect(out.fullDigest).toBe(FULL) + const byLabel = Object.fromEntries(out.identity.map((r) => [r.label, r.value])) + expect(byLabel['Sequence']).toBe('42') + expect(byLabel['Authority']).toBe('operator') + expect(byLabel['Trust epoch']).toBe('3') + expect(byLabel['Signature']).toBe('verified') + expect(byLabel['Age']).toBe('9 days') + }) + + it('reports an unverified signature and singular/unknown staleness plainly', () => { + const a = formatSignatureBundle({ source: 'embedded', runnable_rules: 6, staleness_days: 1 })! + const b = formatSignatureBundle({ source: 'embedded', runnable_rules: 6 })! + const ar = Object.fromEntries(a.identity.map((r) => [r.label, r.value])) + const br = Object.fromEntries(b.identity.map((r) => [r.label, r.value])) + expect(ar['Signature']).toBe('not verified') + expect(ar['Age']).toBe('1 day') + expect(br['Age']).toBeUndefined() + }) + + it('derives the short fingerprint from the full digest when only that is sent', () => { + const out = formatSignatureBundle({ source: 'embedded', runnable_rules: 6, full_digest: FULL })! + expect(out.shortFingerprint).toBe(FULL.slice(0, 12)) + }) + + it('has empty identity extras for a legacy descriptor', () => { + const out = formatSignatureBundle({ source: 'embedded', runnable_rules: 6, fingerprint: 'abc123def456' })! + expect(out.fullDigest).toBe('') + expect(out.degraded).toBeNull() + expect(out.lastRejection).toBeNull() + expect(out.pinned).toBe(false) + }) +}) + +describe('formatSignatureBundle — degraded coverage', () => { + it.each([ + ['unsigned_external_active', /unsigned/i], + ['embedded_below_watermark', /older/i], + ['operator_bundle_missing', /missing|no longer/i], + ['operator_authority_unbaselined', /baseline/i], + ])('explains %s and says auto-approval is suspended', (reason, re) => { + const out = formatSignatureBundle({ + source: 'embedded', + runnable_rules: 6, + coverage_degraded: true, + degraded_reason: reason, + })! + expect(out.degraded).not.toBeNull() + expect(out.degraded!.reason).toBe(reason) + expect(out.degraded!.explanation).toMatch(re) + expect(out.degraded!.explanation.toLowerCase()).toContain('auto-approval') + expect(out.degraded!.explanation.toLowerCase()).toContain('review') + expect(out.tone).toBe('text-warning') + }) + + it('still shows a banner for an unknown future reason, with the raw code', () => { + const out = formatSignatureBundle({ + source: 'embedded', runnable_rules: 6, coverage_degraded: true, degraded_reason: 'some_new_reason', + })! + expect(out.degraded!.explanation).toContain('some_new_reason') + expect(out.degraded!.explanation.toLowerCase()).toContain('auto-approval') + }) + + it('shows a banner even when degraded with no reason', () => { + const out = formatSignatureBundle({ source: 'embedded', runnable_rules: 6, coverage_degraded: true })! + expect(out.degraded).not.toBeNull() + }) + + it('has NO banner when not degraded, even with a reason string left behind', () => { + const out = formatSignatureBundle({ + source: 'embedded', runnable_rules: 6, coverage_degraded: false, degraded_reason: 'unsigned_external_active', + })! + expect(out.degraded).toBeNull() + expect(out.tone).toBe('') + }) + + it('does not downgrade the zero-runnable error tone', () => { + const out = formatSignatureBundle({ source: 'embedded', runnable_rules: 0, coverage_degraded: true, degraded_reason: 'unsigned_external_active' })! + expect(out.tone).toBe('text-error') + }) + + it('describeDegradedReason is exported for direct use', () => { + expect(describeDegradedReason('unsigned_external_active')).toMatch(/unsigned/i) + }) +}) + +describe('formatSignatureBundle — last rejection and pins', () => { + it('surfaces reason, detail and time of the last rejection', () => { + const out = formatSignatureBundle({ + source: 'embedded', + runnable_rules: 6, + last_rejection: { reason: 'downgrade', detail: 'sequence 3 below watermark 5', at: '2026-10-19T10:00:00Z' }, + })! + expect(out.lastRejection).toEqual({ + reason: 'downgrade', + label: expect.stringMatching(/downgrade/i), + detail: 'sequence 3 below watermark 5', + at: '2026-10-19T10:00:00Z', + }) + // A rejection alone is informational: last-known-good is still serving. + expect(out.degraded).toBeNull() + expect(out.tone).toBe('') + }) + + it('ignores an empty last_rejection object', () => { + const out = formatSignatureBundle({ source: 'embedded', runnable_rules: 6, last_rejection: { reason: '', at: '' } })! + expect(out.lastRejection).toBeNull() + }) + + it('reports pinned and rollback flags', () => { + const out = formatSignatureBundle({ source: 'file', runnable_rules: 3, pinned: true, rollback: true })! + expect(out.pinned).toBe(true) + expect(out.rollback).toBe(true) + }) +}) From 58b2e55b20e7243f4534c79cb4691889b2b320d2 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 14:09:55 +0300 Subject: [PATCH 12/35] feat(security): Spec 101 US1 operator documentation for signed TPA bundles Related #938 --- docs/cli/security-commands.md | 19 ++ docs/configuration.md | 4 +- docs/features/security-quarantine.md | 25 ++ docs/features/tool-scanner.md | 21 ++ docs/features/tpa-bundle-signing.md | 341 +++++++++++++++++++++++++++ specs/101-tpa-db/quickstart.md | 107 +++++---- website/sidebars.js | 1 + 7 files changed, 476 insertions(+), 42 deletions(-) create mode 100644 docs/features/tpa-bundle-signing.md diff --git a/docs/cli/security-commands.md b/docs/cli/security-commands.md index 402aa599e..52241a2a7 100644 --- a/docs/cli/security-commands.md +++ b/docs/cli/security-commands.md @@ -686,6 +686,24 @@ 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). + +### Usage + +```bash +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 +``` + +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. + +--- + ## security cancel-all Cancel an in-progress batch scan (started with `security scan --all`). Individual per-scanner containers may still complete, but pending servers are skipped and the batch transitions to `cancelled`. @@ -779,5 +797,6 @@ mcpproxy security report my-server -o sarif > my-server.sarif - [Security Scanner Plugin System](/features/security-scanner-plugins) — architecture and feature overview - [Scanner Images](/features/scanner-images) — which image each scanner uses and why - [Security Quarantine](/features/security-quarantine) — the underlying quarantine mechanism +- [Signed TPA Bundles](/features/tpa-bundle-signing) — signing, rollback and status of the signature corpus - [Activity Commands](/cli/activity-commands) — correlate scans with the activity log - [Command Reference](/cli/command-reference) — top-level CLI index diff --git a/docs/configuration.md b/docs/configuration.md index f340d8474..8baf575c1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -953,7 +953,7 @@ block** — off by default, best-effort, and unable to change the baseline verdi | `require_signed_bundle` | boolean | `false` | When `true`, a file-dropped TPA bundle must carry a valid detached signature; an unsigned or badly signed one is refused and the previous corpus stays live. When `false`, unsigned drops keep working but always run with degraded coverage. | | `tpa_active_root` | string | `"publisher"` | Which signing authority may activate a bundle: `"publisher"` (the keys embedded in this binary) or `"operator"` (the keys in `tpa_operator_signers`). A bundle signed by the other authority is refused. Rejected on write for any other value, and `"operator"` needs at least one signer. | | `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 lowest operator bundle sequence this install will accept, as an anti-downgrade floor. Absent means no floor. An explicit `0` is kept and is different from absent. | +| `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. | | `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. | | `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. | @@ -961,7 +961,7 @@ 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. | -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). 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. **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 0cbeef2f9..016d42df4 100644 --- a/docs/features/security-quarantine.md +++ b/docs/features/security-quarantine.md @@ -636,9 +636,34 @@ Which corpus is live is visible in: - `mcpproxy security overview` — source, version, freshness stamp, fingerprint, and the runnable / skipped / declared-skipped rule split; +- `mcpproxy security bundle` / `GET /api/v1/security/bundle` — the full status: + full digest, sequence, signing authority, trust epoch, whether the signature + verified, staleness, degraded coverage, and the last refused candidate; - `GET /api/v1/security/overview` → `signature_bundle`; - the Web UI **Security** tab ("Signatures (runnable)" stat). +**Signed bundles (Spec 101).** A bundle can carry a detached Ed25519 signature +(`.sig`). A signed bundle is checked against the keys you trust +(`tpa_operator_signers`) and against a per-install anti-downgrade watermark, so +an older genuinely signed corpus cannot be put back. Rejections keep the +previous corpus serving and are reported as `last_rejection`. Unsigned bundles +still load unless `require_signed_bundle` is on, but they always run with +degraded coverage. The bundle is **not** watched: it activates on config +reload, restart, or `mcpproxy security bundle reload`. + +**Degraded coverage suspends `scan`-mode auto-approval.** When MCPProxy cannot +vouch for the corpus that is scanning (an unsigned external bundle, an operator +bundle with no baseline, a lost operator bundle, or an embedded corpus older +than a bundle seen before), new and changed tools are held for human review +with held reason `scan_coverage` instead of being approved on a clean verdict. +The reason is always shown in the status. An ordinary fresh install on the +embedded corpus is not degraded. + +Rollback is by the full SHA-256 digest, not a path or the 12-character +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). + 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 diff --git a/docs/features/tool-scanner.md b/docs/features/tool-scanner.md index d5a8ca530..38df2368e 100644 --- a/docs/features/tool-scanner.md +++ b/docs/features/tool-scanner.md @@ -293,10 +293,31 @@ path have been removed. The engine's two-tier semantics therefore describe the entire in-process detection behavior, with `phrase.injection` (hard) carrying the approval-blocking posture the removed rules used to provide. +## Signature corpus and coverage + +Alongside the seven checks, the `scan` trust mode matches tool text against an +offline corpus of known-attack signatures (the tpa-db `scanner-bundle.json`). +The build embeds one; `security.tpa_bundle_path` points the scanner at another +from disk. A corpus can be signed with an operator Ed25519 key, and a signed +corpus is protected against downgrade by a sequence watermark. + +If a candidate corpus is refused, the previous one keeps serving; the scanner is +never left with an empty or partial rule set. The state of the corpus is +reported by `mcpproxy security bundle`, including **degraded coverage**: +an unsigned external corpus, an operator corpus with no baseline, a lost operator +corpus, or an embedded corpus older than one seen before. **While coverage is +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. + ## Related reading - [Security Scanner Plugins](/features/security-scanner-plugins) — the plugin framework hosting the `tpa-descriptions` scanner - [Security Quarantine](/features/security-quarantine) — the quarantine mechanism hard-tier findings drive - [Tool Quarantine (Spec 032)](/features/tool-quarantine) — per-tool hash-based approval +- [Signed TPA Bundles](/features/tpa-bundle-signing) — signing, dropping, rolling back and monitoring the signature corpus - [Sensitive-Data Detection](/features/sensitive-data-detection) — the shared secret matchers the embedded-secret check reuses - Spec: `specs/076-deterministic-tool-scanner/spec.md` · engine contract: `internal/security/detect/doc.go` diff --git a/docs/features/tpa-bundle-signing.md b/docs/features/tpa-bundle-signing.md new file mode 100644 index 000000000..cabf2b380 --- /dev/null +++ b/docs/features/tpa-bundle-signing.md @@ -0,0 +1,341 @@ +--- +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] +--- + +# 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)). + +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). + +## The operator lifecycle at a glance + +1. Generate a signing key pair with `tpa-db keygen`. Keep the private key offline. +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`. + +## What is signed, and why + +A bundle is a JSON file of detection rules. If an attacker can write to the place MCPProxy reads it from, they can weaken the scanner by removing rules, or by putting back an older copy that predates a rule you rely on. Both are quiet failures: the scanner keeps running and reports a clean result. + +A signed bundle closes the first hole. The signature is a detached Ed25519 signature over the exact raw bytes of the bundle file. It lives in a small sidecar file, `.sig`, beside the bundle. Change one byte of the bundle, even whitespace, and the signature no longer verifies. The sidecar's own formatting (whitespace, key order) is not signed, and does not need to be. + +The anti-downgrade state closes the second hole. Each signed bundle carries a `sequence` number, and the proxy remembers the highest one it has activated. It refuses anything lower. See [Anti-downgrade](#anti-downgrade). + +The sidecar is one JSON line with exactly four keys: + +```json +{"sidecar_version":"1","algorithm":"ed25519","key_id":"3f0a…64 hex","signature":"<88 base64 chars>"} +``` + +You never write it by hand; `tpa-db sign` produces it. The full grammar is in `specs/101-tpa-db/contracts/sidecar-format.md`. + +## 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. + +| Key | Default | Meaning | +|---|---|---| +| `tpa_bundle_path` | `""` (embedded) | Where the bundle file is. The sidecar is always `.sig`. `MCPPROXY_TPA_BUNDLE_PATH` overrides the path only. | +| `require_signed_bundle` | `false` | When `true`, a bundle without a valid sidecar is refused (`signature_required`). | +| `tpa_active_root` | `"publisher"` | Which authority may activate a bundle: `"publisher"` or `"operator"`. | +| `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`. | + +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. + +## Keys and key ids + +`tpa-db` is the operator tool in this repository. Build it with `go build -o tpa-db ./cmd/tpa-db`. + +```bash +tpa-db keygen --out ./keys +# public_key: +# key_id: <64 lowercase hex characters> +``` + +`keygen` writes `operator.key` (the private key, mode 0600) and `operator.pub`, and prints both the public key and the key id. It never overwrites existing files. + +- The **public key** is what goes into `tpa_operator_signers`. Config holds public keys only, never the private key and never the key id. +- The **key id** is the SHA-256 of the raw public key. It is a fingerprint of the key, not a label you choose. You copy it into the bundle manifest as `key_id`. + +Because the id is derived from the key, a manifest that names one key while the bytes are signed by another is detected as a mismatch, not shrugged off. + +Keep `operator.key` off the machine that runs MCPProxy. Only the public key needs to be there. + +## Signing + +The operator authors four fields in the bundle's own manifest, because the signer never edits the bundle: + +| Manifest field | Form | Rule | +|---|---|---| +| `sequence` | a JSON **string** of a decimal number, for example `"2"` | Matches `^[1-9][0-9]{0,19}$` and fits in 64 bits. A bare number, `"0"` and `"01"` are refused. It must be strictly above your `tpa_operator_baseline` and above the sequence already activated. | +| `trust_epoch` | a JSON integer, `0` or more | Must equal `tpa_operator_epoch` exactly. | +| `key_id` | 64 lowercase hex characters | The id printed by `keygen` for the key you sign with. | +| `generated_at` | RFC 3339 timestamp | The freshness stamp that drives `staleness_days`. | + +One way to stamp them with `jq`, then sign: + +```bash +KEY_ID= +jq --arg id "$KEY_ID" --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '. + {sequence: "2", trust_epoch: 0, key_id: $id, generated_at: $now}' \ + corpus.json > scanner-bundle.json + +tpa-db sign --bundle scanner-bundle.json --key ./keys/operator.key +# writes scanner-bundle.json.sig next to the bundle +``` + +Pass `--out PATH` to write the bundle copy to `PATH` and the sidecar to `PATH.sig` instead of signing in place. + +`sign` refuses a bundle whose manifest `key_id` is not the id of the signing key (`key_id_mismatch`), because every loader would reject that pair. It signs whatever bytes it is given; it does not run the detection-quality gate in this release. Any edit to the bundle after signing, even reformatting, invalidates the signature, so sign last. + +Check a pair offline before you move it: + +```bash +tpa-db verify --bundle scanner-bundle.json --signer +# prints OK, or the rejection code and exits 1 +``` + +## Dropping and activating + +Put `scanner-bundle.json` and `scanner-bundle.json.sig` at `tpa_bundle_path`. Write the bundle and then the sidecar, or move both in together; a bundle seen without its sidecar is judged without one. + +**There is no file watcher on the bundle.** Only `mcp_config.json` is watched. A dropped bundle becomes active when one of these happens: + +- 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 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. + +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. + +## Reading the status + +```bash +mcpproxy security bundle # human table +mcpproxy security bundle -o json # or -o yaml +``` + +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`. | +| `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. | +| `fingerprint` | The first 12 hex characters of the digest. A human label only. | +| `full_digest` | The complete 64-hex SHA-256 of the bundle bytes. This is the only value rollback accepts. | +| `sequence`, `authority`, `trust_epoch` | The manifest sequence (decimal string), the root that verified it (`publisher` or `operator`; empty for an unsigned file), and its epoch. The embedded default reports `publisher`. | +| `signature_verified` | `true` when a detached signature verified. Always `false` for the embedded default, which is trusted because it is part of the binary. | +| `coverage_degraded`, `degraded_reason` | Whether scan-mode auto-approval is suspended, and why. | +| `pinned`, `rollback` | A rollback pin is in force; the active bundle came from a rollback. | +| `load_error` | `: ` for the latest attempt to load the configured path, if it was refused. Empty after a successful load. | +| `last_rejection` | `{reason, detail, at}` for the most recent refused candidate. It survives a later successful load, so "what did it refuse, and when" is still there. `reason` is the only field to branch on; `detail` is for people. | + +The human table always prints the full digest in full, because you will paste it into a rollback. When coverage is degraded it prints a line `DEGRADED: `. + +### Rejection reasons + +| `last_rejection.reason` | What it means | +|---|---| +| `tamper` | The signature did not verify over the bundle bytes, or the manifest, sidecar and verifying key disagree on the key id. `detail` says which (`signature_invalid` or `key_id_mismatch`). Re-fetch the pair; if the bytes are yours, re-sign. | +| `downgrade` | Sequence is at or below this authority's watermark for the current epoch, or equals it with different bytes. `detail` gives the sequence and the watermark. Sign a higher sequence. | +| `stale_epoch_replay` | The bundle's `trust_epoch` is below the current epoch. A validly signed but superseded artifact. Re-sign at the current epoch. | +| `epoch_ahead` | The bundle's `trust_epoch` is above the current epoch. Either `tpa_operator_epoch` was not raised yet, or (for publisher bundles) this binary is older than the publisher's recovery release; update MCPProxy. Does not degrade coverage. | +| `unsigned_refused_by_ratchet` | A signed bundle has activated on this install before, so unsigned drops are refused from then on. Add the sidecar. | +| `signature_required` | `require_signed_bundle` is on and there is no sidecar. | +| `authority_not_active` | Verified by a real key of the root that is not `tpa_active_root`. `detail` names both. Either the wrong key signed it, or the declared root is wrong. | +| `key_not_trusted` | The sidecar's key id is in neither trust set. Check `tpa_operator_signers`. Publisher-signed bundles land here today (see below). | +| `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. | +| `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). | +| `read_error` | The file could not be opened or read: missing, permission denied, I/O error. | +| `rollback_target_mismatch` | A rollback named a digest that does not match the bytes found. | +| `state_commit_failed` | The anti-downgrade state could not be saved, so the candidate was not activated. Activating without a durable watermark would reopen the replay window. Check the data directory and disk space. | + +### Degraded coverage + +Degraded coverage means MCPProxy cannot vouch for the corpus that is scanning. **While coverage is degraded, `scan`-mode auto-approval is suspended: new and changed tools are held for human review (held reason `scan_coverage`) instead of being approved on a clean verdict.** Nothing else changes, and the scanner keeps scanning. The reason is always shown; it is never a silent flag. + +| `degraded_reason` | When | What to do | +|---|---|---| +| `unsigned_external_active` | An unsigned file is the active corpus. | Sign it, or remove `tpa_bundle_path` to return to the embedded corpus. | +| `operator_authority_unbaselined` | An operator-signed bundle is active but `tpa_operator_baseline` is not set. Without a floor, a data-directory wipe leaves no downgrade protection. | Set `tpa_operator_baseline` (an explicit `0` counts). | +| `operator_bundle_missing` | The operator root is active and a bundle you activated before is gone or no longer valid, so the embedded corpus is serving. | Restore the file, or fix the rejection shown in `last_rejection`. | +| `embedded_below_watermark` | The embedded corpus is serving and its sequence is below the publisher watermark, meaning it is older than something that was activated before. | Update MCPProxy, or restore the newer bundle. Not reachable until a publisher key ships. | + +These cases are deliberately not degraded: a fresh install on the embedded corpus, and an embedded corpus at or above the publisher watermark after an external file was removed. Degrading there would turn auto-approval off on every new install. + +## Anti-downgrade + +The proxy keeps a small amount of state per signing authority and decides every candidate against it. + +- **Authorities.** There are two: `publisher` (keys embedded in the binary) and `operator` (keys in `tpa_operator_signers`). Each has its own watermark. Sequences from different authorities are never compared. A key listed under both is treated as publisher only. +- **Active root.** Only the authority named by `tpa_active_root` may activate a bundle. A bundle verified by the other authority is `authority_not_active`. Switching the root deactivates a bundle from the old root at the next load. +- **Watermark.** The highest sequence ever activated for this authority in this epoch. A candidate must have a sequence strictly above it. An equal sequence is accepted only if it is byte-identical to the release that set the watermark (for example after a restart). +- **Baseline.** The watermark never starts below the baseline. For the publisher it comes from the embedded bundle. For the operator it comes from `tpa_operator_baseline`, read from config on every start. This is what protects you after the data directory is wiped: the floor does not live there. +- **Epoch.** Every comparison is by (authority, epoch, sequence), epoch first. Raising the epoch retires everything signed under the old one, regardless of its sequence. +- **Ratchet.** Once any signature-verified external bundle has activated, unsigned drops are refused for good, even with `require_signed_bundle` off. +- **Unsigned drops.** An unsigned file cannot move the watermark and its manifest is never trusted. It activates only if no ratchet and no `require_signed_bundle` stop it, and then it runs with degraded coverage. + +`require_signed_bundle` defaults to **off**, so unsigned drops keep working for people who have never heard of signing. That has two consequences. First, an unsigned bundle is accepted until the ratchet is set, and the ratchet is stored in the data directory, which a wipe resets. Second, nothing stops someone who can write the bundle path from dropping an unsigned corpus; the only visible sign is degraded coverage. If anyone other than you can write `tpa_bundle_path`, set `require_signed_bundle: true`. + +## Rolling back + +Rollback is the one operation that deliberately goes below the watermark, so it is authorized by the **full SHA-256 digest** of the target, never by a path and never by the 12-character fingerprint. The path only says where to look for the bytes. The digest is what proves they are the ones you meant. A 12-hex fingerprint is 48 bits, fine as a label and not strong enough to authorize a bypass when an attacker could choose the file. The CLI and the REST endpoint refuse it with a message that the full digest is required. + +Find the digest in the history, which lists every activation, oldest first: + +```bash +mcpproxy security bundle history -o json | jq '.[] | {sequence, full_digest, source, activated_at}' +``` + +The history is how you learn a known-good digest without reading it from the file the attacker may control. It makes a rollback possible; it does not authorize one. + +Then roll back, naming exactly one target: + +```bash +# to a bundle file (its sidecar is PATH.sig) +mcpproxy security bundle rollback --path /var/lib/tpa/scanner-bundle.v1.json --digest <64-hex> + +# to the corpus compiled into the binary +mcpproxy security bundle rollback --embedded --digest <64-hex of the embedded corpus> +``` + +The embedded corpus's digest is the `full_digest` shown by `mcpproxy security bundle` while the embedded corpus is serving. + +The same operation over REST is `POST /api/v1/security/bundle/rollback` with `{"embedded": bool, "path": string, "digest": string}`. It returns 400 for a usage error (both or neither of `embedded` and `path`, or a digest that is not 64 lowercase hex) and 409 with the rejection when the rollback is refused. + +What a successful rollback does: + +- The target serves. It still has to pass signature, epoch and size checks; only the watermark is bypassed. A target below your operator baseline is refused (`downgrade`, "below operator baseline"). +- The target is **pinned**. While the pin holds, only the pinned digest may activate at or below the watermark. A bundle strictly above the watermark still activates normally and clears the pin. +- The signed release you rolled away from is **deny-listed**, so the next reload cannot bring it back. If its file is still at `tpa_bundle_path` it is refused as `deny_listed`. +- The watermark is **not lowered**. +- The override is logged at WARN and a history row is written with `rollback: true`. + +A wrong digest is refused as `rollback_target_mismatch`, and the corpus that was serving keeps serving. + +To return to normal watermark governance: + +```bash +mcpproxy security bundle unpin +``` + +Unpin clears the pin and re-checks the serving bundle without it. If the rolled-back bundle is at or below the watermark it no longer passes, and the install falls back to the embedded corpus under the degraded-coverage rules above. The deny-list is kept. + +## Key compromise + +If an operator private key may have leaked: + +1. Generate a new key pair (`tpa-db keygen`). +2. In config, replace the old public key in `tpa_operator_signers` with the new one, raise `tpa_operator_epoch` by one, and set `tpa_operator_baseline` to a value below the sequences you are about to sign. +3. Author and sign a new bundle with the new `key_id`, `trust_epoch` equal to the new epoch, and a sequence above the new baseline. +4. Drop it and run `mcpproxy security bundle reload`. + +After the epoch change, anything signed under the old epoch is `stale_epoch_replay`, and anything signed by the old key is `key_not_trusted`, so an attacker holding the old key can replay nothing. The operator watermark restarts from the baseline, and pins and deny-list entries from the earlier epoch are retired (logged at WARN). + +Publisher epoch bumps are different: they arrive only with a new MCPProxy binary. A bundle with a higher epoch than your binary knows is refused `epoch_ahead` until you update. + +## Air-gapped walkthrough + +On the signing machine (offline): + +```bash +tpa-db keygen --out ./keys # note public_key and key_id +jq --arg id "$KEY_ID" --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '. + {sequence: "2", trust_epoch: 0, key_id: $id, generated_at: $now}' \ + corpus.json > scanner-bundle.json +tpa-db sign --bundle scanner-bundle.json --key ./keys/operator.key +tpa-db verify --bundle scanner-bundle.json --signer "$PUBLIC_KEY" # OK +``` + +Carry `scanner-bundle.json` and `scanner-bundle.json.sig` to the target on removable media. + +On the target, set the config once: + +```json +{ + "security": { + "tpa_bundle_path": "/var/lib/mcpproxy/tpa/scanner-bundle.json", + "tpa_active_root": "operator", + "tpa_operator_signers": [""], + "tpa_operator_baseline": 1, + "tpa_operator_epoch": 0, + "require_signed_bundle": true + } +} +``` + +Copy the pair to `tpa_bundle_path`, run `mcpproxy security bundle reload`, and check `mcpproxy security bundle`: `signature_verified: true`, `authority: operator`, `coverage_degraded: false`. Every later release is the same three steps with a higher sequence. The private key never leaves the signing machine, and nothing here needs a network. + +## Threat model + +**In scope:** an attacker who can write the bundle file or its sidecar, who can plant a symlink or FIFO there, who can replay an older but genuinely signed bundle, or who holds a key that has since been retired by an epoch bump. + +**Out of scope:** an attacker who can write `mcp_config.json`. They choose the path, the trusted keys, the active root, `require_signed_bundle` and the baseline, so they choose the policy itself. Protect the config file the way you protect the rest of your trust decisions. + +**The data directory is best effort.** The watermark above the baseline, the signature ratchet, the pin, the deny-list, the activation history and the last-known-good copies all live in `config.db` and the data directory. Someone who can wipe or substitute them can reset the ratchet, lower the watermark or forge a pin. Do not rely on them against that attacker. + +What still holds after a data-directory wipe or a substituted `config.db`: + +| Survives | Why | +|---|---| +| Signature verification | Keys come from the binary and from config, not from the data directory. | +| Publisher baseline | Re-read from the embedded bundle on every start. | +| Operator baseline, epoch, active root, `require_signed_bundle` | They are config values. | +| Size and file-type limits | Enforced on every read. | + +What does not survive: the ratchet, the watermark between the baseline and your latest release, the pin, the deny-list, the history. After a wipe, an older operator-signed bundle with a sequence above your baseline can be activated again. To limit that, raise `tpa_operator_baseline` each time you retire older releases for good. A last-known-good file whose digest does not match its recorded name is treated as missing, and the embedded corpus serves instead. + +## Platform notes + +The bundle and sidecar are opened without following symlinks, and the checks are made on the open file descriptor, not on the path, so swapping the path between the check and the open gains an attacker nothing. + +- **Linux and macOS:** the file is opened with `O_NOFOLLOW` and `O_NONBLOCK`, checked with `fstat` to be a regular file, then switched to blocking reads. A FIFO never blocks the loader; it is refused as `not_regular_file`. +- **Windows:** the file is opened by handle with `FILE_FLAG_OPEN_REPARSE_POINT`. Reparse points (symlinks, junctions) and directories are refused. +- **Network filesystems:** a read can stall in the kernel and cannot be cancelled. The loader gives each candidate a 5 second budget and then refuses it as `read_timeout`, releasing the pipeline so the serving corpus is untouched. The stuck read parks one goroutine on that path. While it is parked, further reads of the same path are refused immediately instead of piling up more goroutines. If you see repeated `read_timeout`, fix the mount; do not point `tpa_bundle_path` at a flaky share. +- A symlinked `tpa_bundle_path` is refused. Configure the real file path. + +## REST and CLI reference + +| CLI | REST | Auth | +|---|---|---| +| `mcpproxy security bundle` | `GET /api/v1/security/bundle` | admin read | +| `mcpproxy security bundle history` | `GET /api/v1/security/bundle/history` | admin read | +| `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 | + +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. + +## See also + +- [Security Quarantine: Signature bundle](/features/security-quarantine#signature-bundle-offline-tpa-corpus) +- [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/specs/101-tpa-db/quickstart.md b/specs/101-tpa-db/quickstart.md index ac10283f1..77de22372 100644 --- a/specs/101-tpa-db/quickstart.md +++ b/specs/101-tpa-db/quickstart.md @@ -22,43 +22,50 @@ EOF ## 1. Generate a signing key ```bash -go run ./cmd/tpa-db keygen --out "$SCRATCH/keys" -# writes keys/operator.key (private, 0600) and keys/operator.pub -# prints the key id: the lowercase-hex SHA-256 of the 32-byte raw public key +KG=$(go run ./cmd/tpa-db keygen --out "$SCRATCH/keys") +echo "$KG" +# public_key: +# key_id: <64 lowercase hex> +PUBKEY=$(sed -n 's/^public_key: //p' <<<"$KG") +KEY_ID=$(sed -n 's/^key_id: //p' <<<"$KG") +# keys/operator.key (private, 0600) and keys/operator.pub were written; keygen never overwrites ``` -The **key id is a fingerprint of the key**, never a label you choose (FR-003). That is what makes "the manifest names one publisher, the bytes are signed by another" detectable rather than cosmetic. +The **key id is a fingerprint of the key**, never a label you choose (FR-003): the lowercase-hex SHA-256 of the 32-byte raw public key. That is what makes "the manifest names one publisher, the bytes are signed by another" detectable rather than cosmetic. Config holds the **public key** (`PUBKEY`); the manifest holds the **key id**. ## 2. Sign a bundle +The signer never edits the bundle. You author `sequence`, `trust_epoch`, `key_id` and `generated_at` in the bundle's own manifest, then sign the exact bytes. The seed corpus does not carry these yet, so stamp them with `jq`. The sequence is a decimal **string**, and it must be strictly above `tpa_operator_baseline` (step 3 uses baseline 1, so this bundle is sequence 2): + ```bash -go run ./cmd/tpa-db sign \ - --bundle internal/security/scanner/bundled/scanner-bundle.json \ - --key "$SCRATCH/keys/operator.key" \ - --sequence 1 \ - --epoch 0 \ - --out "$SCRATCH/scanner-bundle.json" -# writes scanner-bundle.json and scanner-bundle.json.sig +jq --arg id "$KEY_ID" --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '. + {sequence: "2", trust_epoch: 0, key_id: $id, generated_at: $now}' \ + internal/security/scanner/bundled/scanner-bundle.json > "$SCRATCH/scanner-bundle.json" + +go run ./cmd/tpa-db sign --bundle "$SCRATCH/scanner-bundle.json" --key "$SCRATCH/keys/operator.key" +# writes scanner-bundle.json.sig next to the bundle +go run ./cmd/tpa-db verify --bundle "$SCRATCH/scanner-bundle.json" --signer "$PUBKEY" # OK ``` -`sign` refuses a bundle that fails the eval gate — FR-016 means a bundle that cannot pass is never signed, so the gate runs here rather than only in CI. +`sign` refuses a bundle whose manifest `key_id` is not the id of the signing key (`key_id_mismatch`): every loader would reject that pair. It does not run the detection-quality gate in this release; gate-before-sign arrives with User Story 2. -Inspect the sidecar; it is one line, five keys, nothing else ([contract](contracts/sidecar-format.md)): +Inspect the sidecar; it is one line, four keys, nothing else ([contract](contracts/sidecar-format.md)): ```bash cat "$SCRATCH/scanner-bundle.json.sig" | jq . +# sidecar_version, algorithm, key_id, signature ``` ## 3. Configure the operator authority and drop the bundle -An operator-signed bundle needs three config decisions, all of them **config-resident on purpose**: the config-write attacker is out of the threat model, the bundle-path attacker is in it. +An operator-signed bundle needs these config decisions, all of them **config-resident on purpose**: the config-write attacker is out of the threat model, the bundle-path attacker is in it. `tpa_operator_signers` holds **public keys**, not key ids. ```json { "security": { "tpa_bundle_path": "/scanner-bundle.json", - "tpa_active_authority": "operator", - "tpa_operator_keys": [""], + "tpa_active_root": "operator", + "tpa_operator_signers": [""], "tpa_operator_baseline": 1, "tpa_operator_epoch": 0, "require_signed_bundle": true @@ -66,77 +73,97 @@ An operator-signed bundle needs three config decisions, all of them **config-res } ``` -Declaring `tpa_operator_baseline` matters more than it looks. Without it the authority runs **explicitly degraded** rather than silently trusted — with no baseline there is no anti-downgrade at all after a data-directory wipe, and an attacker who can write the bundle path could replay any older genuinely operator-signed bundle at full coverage (FR-009b). +Declaring `tpa_operator_baseline` matters more than it looks. Without it the authority runs **explicitly degraded** rather than silently trusted: with no baseline there is no anti-downgrade at all after a data-directory wipe, and an attacker who can write the bundle path could replay any older genuinely operator-signed bundle at full coverage (FR-009b). -Start the proxy; the file watcher picks the bundle up without a restart. +Start the proxy. The bundle is read at startup: ```bash ./mcpproxy serve --config "$SCRATCH/config.json" --data-dir "$SCRATCH/data" --log-level=debug ``` +**There is no file watcher on the bundle**: only `mcp_config.json` is watched. After a later drop, activate it with a config reload, a restart, or the reload command, which runs the same single-flight pipeline: + +```bash +./mcpproxy security bundle reload --config "$SCRATCH/config.json" --data-dir "$SCRATCH/data" +# POST /api/v1/security/bundle/reload +``` + ## 4. Verify it activated ```bash ./mcpproxy security bundle -o json --config "$SCRATCH/config.json" --data-dir "$SCRATCH/data" | jq . ``` -Expect `signature_verified: true`, `authority: "operator"`, `trust_epoch: 0`, `sequence: "1"`, a `full_digest`, `coverage_degraded: false`. +Expect `signature_verified: true`, `authority: "operator"`, `trust_epoch: 0`, `sequence: "2"`, a 64-hex `full_digest`, `coverage_degraded: false`. -**If `coverage_degraded` is true, read `degraded_reason` before anything else** — it distinguishes "you forgot the baseline" (`operator_authority_unbaselined`) from "the bundle is unsigned" (`unsigned_external_active`) from "we fell back to the embedded default" (`embedded_below_watermark`). A degraded install has `scan`-mode auto-approval suspended, which is otherwise invisible. +**If `coverage_degraded` is true, read `degraded_reason` before anything else** — it distinguishes "you forgot the baseline" (`operator_authority_unbaselined`) from "the bundle is unsigned" (`unsigned_external_active`) from "we fell back to the embedded default" (`operator_bundle_missing` / `embedded_below_watermark`). A degraded install has `scan`-mode auto-approval suspended, which is otherwise invisible. ## 5. Watch a downgrade be refused -Sign the same corpus at a lower sequence and drop it: +Keep the good bundle, then sign the same corpus at a genuinely **lower** sequence (1, which is not above the watermark 2) and put it at the configured path: ```bash -go run ./cmd/tpa-db sign --bundle … --sequence 1 --epoch 0 --out "$SCRATCH/old.json" -cp "$SCRATCH/old.json"* "$SCRATCH/" # overwrite the configured path +mkdir "$SCRATCH/v2" "$SCRATCH/v1" +cp "$SCRATCH"/scanner-bundle.json* "$SCRATCH/v2/" + +jq --arg id "$KEY_ID" --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '. + {sequence: "1", trust_epoch: 0, key_id: $id, generated_at: $now}' \ + internal/security/scanner/bundled/scanner-bundle.json > "$SCRATCH/v1/scanner-bundle.json" +go run ./cmd/tpa-db sign --bundle "$SCRATCH/v1/scanner-bundle.json" --key "$SCRATCH/keys/operator.key" + +cp "$SCRATCH/v1/scanner-bundle.json" "$SCRATCH/v1/scanner-bundle.json.sig" "$SCRATCH/" # overwrite the configured path +./mcpproxy security bundle reload --config "$SCRATCH/config.json" --data-dir "$SCRATCH/data" ``` -The sequence-1 artifact is now at or below the watermark, so it is refused and the current bundle keeps serving: +The sequence-1 artifact is at or below the watermark, so it is refused and the current bundle keeps serving: ```bash -./mcpproxy security bundle -o json … | jq '.last_rejection' -# { "reason": "downgrade", "at": "…" } +./mcpproxy security bundle -o json --config "$SCRATCH/config.json" --data-dir "$SCRATCH/data" | jq '{sequence, last_rejection}' +# { "sequence": "2", "last_rejection": { "reason": "downgrade", "detail": "sequence 1, watermark 2 …", "at": "…" } } ``` -Nothing about this is fatal — every rejection keeps last-known-good. That is the point: a refused candidate must never leave the scanner with an empty or partial rule set. +Nothing about this is fatal: every rejection keeps last-known-good. That is the point: a refused candidate must never leave the scanner with an empty or partial rule set. -## 6. Roll back — by digest, not by path +## 6. Roll back — by full digest, not by path Read the history to learn the digest you want: ```bash -./mcpproxy security bundle history -o json … | jq '.[] | {sequence, full_digest, activated_at}' +./mcpproxy security bundle history -o json --config "$SCRATCH/config.json" --data-dir "$SCRATCH/data" \ + | jq '.[] | {sequence, full_digest, activated_at}' ``` -Then roll back naming **the full digest**: +The history only lists releases that activated, so it will not contain the sequence-1 file. Take its digest from the file you hold, which is fine here because you made it; in an incident, take it from the history or from your release record, never from the file under suspicion: ```bash -./mcpproxy security bundle rollback \ - --path "$SCRATCH/old.json" \ - --digest … +V1=$(shasum -a 256 "$SCRATCH/v1/scanner-bundle.json" | cut -d' ' -f1) +./mcpproxy security bundle rollback --path "$SCRATCH/v1/scanner-bundle.json" --digest "$V1" \ + --config "$SCRATCH/config.json" --data-dir "$SCRATCH/data" +# or, to go back to the corpus compiled into the binary: rollback --embedded --digest ``` -The path is a *hint about where to find those bytes*; the digest is the authorization. Rollback is the one path that deliberately bypasses the watermark, so identifying its target by an attacker-writable path would hand the attacker the choice of what the bypass lands on. Supply the wrong digest and it is refused `rollback_target_mismatch` — try it, it is one command: +Give **exactly one** of `--embedded` or `--path`. The path is a *hint about where to find those bytes*; the digest is the authorization. Rollback is the one path that deliberately bypasses the watermark, so identifying its target by an attacker-writable path would hand the attacker the choice of what the bypass lands on. A target below your operator baseline is refused. Supply the wrong digest and it is refused `rollback_target_mismatch` — try it, it is one command: ```bash -./mcpproxy security bundle rollback --path "$SCRATCH/old.json" --digest 0000… # refused +./mcpproxy security bundle rollback --path "$SCRATCH/v1/scanner-bundle.json" \ + --digest 0000000000000000000000000000000000000000000000000000000000000000 … # refused (409) ``` -**The 12-hex short fingerprint is not accepted here.** It is 48 bits — fine as a human label, not as authorization for a watermark bypass where the attacker chooses the substituted bytes. +**The 12-hex short fingerprint is not accepted here** (400, "the full digest is required"). It is 48 bits — fine as a human label, not as authorization for a watermark bypass where the attacker chooses the substituted bytes. -After a rollback the artifact is **pinned** and the release you rolled away from is **deny-listed**, so the next refresh cycle cannot re-activate the very thing you just rejected. Clear the pin to return to normal watermark-governed activation: +After a rollback the artifact is **pinned**, the release you rolled away from (sequence 2) is **deny-listed**, and the watermark stays at 2. Put the sequence-2 file back at the configured path and reload to see `deny_listed`: the next refresh cycle cannot re-activate the very thing you just rejected. Clear the pin to return to normal watermark-governed activation: ```bash -./mcpproxy security bundle unpin … +./mcpproxy security bundle unpin --config "$SCRATCH/config.json" --data-dir "$SCRATCH/data" ``` +With the pin gone, the sequence-1 bundle no longer passes the watermark, so the install falls back to the embedded default and reports the matching degraded reason. + ## 7. Air-gapped check (SC-003) -Steps 1–6 with the network unavailable behave identically. That is the criterion — the file-drop path is not a degraded mode, it is the primary one, and the fetch path adds convenience rather than capability. +Steps 1–6 with the network unavailable behave identically. That is the criterion — the file-drop path is not a degraded mode, it is the primary one, and the fetch path adds convenience rather than capability. The operator-facing version of this walkthrough is `docs/features/tpa-bundle-signing.md`. -## 8. Contributor loop +## 8. Contributor loop (User Story 2, not in the first PR) ```bash go run ./cmd/tpa-db validate --bundle # schema + signatures[] integrity + licences diff --git a/website/sidebars.js b/website/sidebars.js index f8975e67f..7b3071c6e 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -112,6 +112,7 @@ const sidebars = { 'features/security-quarantine', 'features/tool-quarantine', 'features/tool-scanner', + 'features/tpa-bundle-signing', 'features/security-scanner-plugins', 'features/sensitive-data-detection', 'features/audit-log', From 14616c4c74cf6436980e5dcd39c17ac51238910a Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 14:19:10 +0300 Subject: [PATCH 13/35] feat(security): Spec 101 US1 bundle REST routes and security bundle CLI Add GET /security/bundle, GET /security/bundle/history and POST /security/bundle/{rollback,unpin,reload}. The reads are admin-read; the mutations sit behind a new agent-denied server op (bundle_manage). A short fingerprint is refused with 400, a refused rollback returns 409 carrying the rejection reason. Add the mcpproxy security bundle command group (status, history, rollback, unpin, reload) and extend signatureBundleLines with the full digest, sequence, authority, epoch, signature verdict, DEGRADED coverage line, pin flags and the last rejection. Rollback validates its flags locally before any request. Related #938 --- cmd/mcpproxy/security_bundle_cmd_test.go | 261 +++++++++++ cmd/mcpproxy/security_cmd.go | 405 +++++++++++++++++- internal/auth/server_ops.go | 5 + internal/auth/server_ops_test.go | 1 + internal/httpapi/agent_token_gating_test.go | 3 + .../httpapi/scope_route_table_guard_test.go | 2 + internal/httpapi/security_bundle.go | 207 +++++++++ internal/httpapi/security_bundle_test.go | 317 ++++++++++++++ internal/httpapi/security_scanner.go | 8 + internal/httpapi/security_scanner_test.go | 3 + internal/httpapi/server.go | 9 + oas/docs.go | 4 +- oas/swagger.yaml | 271 ++++++++++++ 13 files changed, 1483 insertions(+), 13 deletions(-) create mode 100644 cmd/mcpproxy/security_bundle_cmd_test.go create mode 100644 internal/httpapi/security_bundle.go create mode 100644 internal/httpapi/security_bundle_test.go diff --git a/cmd/mcpproxy/security_bundle_cmd_test.go b/cmd/mcpproxy/security_bundle_cmd_test.go new file mode 100644 index 000000000..6ecc8e8db --- /dev/null +++ b/cmd/mcpproxy/security_bundle_cmd_test.go @@ -0,0 +1,261 @@ +package main + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const bundleCLIDigest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +func bundleCLIStatusBody() string { + return `{ + "source": "file", + "path": "/etc/mcpproxy/scanner-bundle.json", + "bundle_version": "2026.10.1", + "schema_version": "1", + "signature_count": 12, + "runnable_rules": 10, + "skipped_rules": 1, + "declared_skipped": 1, + "generated_at": "2026-09-01T00:00:00Z", + "fingerprint": "0123456789ab", + "full_digest": "` + bundleCLIDigest + `", + "sequence": "42", + "authority": "publisher", + "trust_epoch": 3, + "signature_verified": true, + "staleness_days": 49, + "coverage_degraded": true, + "degraded_reason": "operator_bundle_missing", + "pinned": true, + "rollback": true, + "loaded_at": "2026-10-20T00:00:00Z", + "last_rejection": {"reason": "downgrade", "detail": "sequence 7 below watermark 42", "at": "2026-10-19T12:00:00Z"} +}` +} + +func TestSecurityBundleStatus_HumanTableShowsEveryOperatorField(t *testing.T) { + newRESTRecorder(t, map[string]cannedResponse{"GET /api/v1/security/bundle": okResp(bundleCLIStatusBody())}) + + out, _, err := runCLI(t, GetSecurityCommand, "table", "bundle") + require.NoError(t, err) + + assert.Contains(t, out, bundleCLIDigest, "the full digest is the rollback input and must be shown in full") + for _, want := range []string{ + "42", // sequence + "publisher", // authority + "3", // epoch + "verified", // signature + "49", // staleness days + "DEGRADED: operator_bundle_missing", + "pinned", + "rollback", + "downgrade", // last rejection reason + "sequence 7 below watermark 42", // detail + "2026-10-19", // when + } { + assert.Contains(t, out, want) + } +} + +func TestSecurityBundleStatus_HealthyHasNoDegradedLine(t *testing.T) { + body := `{"source":"embedded","runnable_rules":6,"full_digest":"` + bundleCLIDigest + `","fingerprint":"0123456789ab","sequence":"5","authority":"publisher","trust_epoch":1,"signature_verified":false,"coverage_degraded":false}` + newRESTRecorder(t, map[string]cannedResponse{"GET /api/v1/security/bundle": okResp(body)}) + + out, _, err := runCLI(t, GetSecurityCommand, "table", "bundle") + require.NoError(t, err) + assert.NotContains(t, out, "DEGRADED") + assert.NotContains(t, out, "pinned") + assert.NotContains(t, out, "Last rejection") +} + +func TestSecurityBundleStatus_JSONAndYAMLPassThrough(t *testing.T) { + newRESTRecorder(t, map[string]cannedResponse{"GET /api/v1/security/bundle": okResp(bundleCLIStatusBody())}) + + out, _, err := runCLI(t, GetSecurityCommand, "json", "bundle") + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &got), out) + assert.Equal(t, bundleCLIDigest, got["full_digest"]) + assert.Equal(t, true, got["coverage_degraded"]) + + out, _, err = runCLI(t, GetSecurityCommand, "yaml", "bundle") + require.NoError(t, err) + assert.Contains(t, out, "full_digest: "+bundleCLIDigest) +} + +func TestSecurityBundleHistory(t *testing.T) { + body := `[ + {"authority":"publisher","trust_epoch":3,"sequence":"41","full_digest":"` + strings.Repeat("a", 64) + `","generated_at":"2026-08-01T00:00:00Z","activated_at":"2026-08-02T00:00:00Z","source":"file","signature_verified":true}, + {"authority":"publisher","trust_epoch":3,"sequence":"42","full_digest":"` + bundleCLIDigest + `","generated_at":"2026-09-01T00:00:00Z","activated_at":"2026-09-02T00:00:00Z","source":"file","signature_verified":true,"rollback":true} +]` + newRESTRecorder(t, map[string]cannedResponse{"GET /api/v1/security/bundle/history": okResp(body)}) + + out, _, err := runCLI(t, GetSecurityCommand, "table", "bundle", "history") + require.NoError(t, err) + assert.Contains(t, out, strings.Repeat("a", 64), "history digests are the rollback input and are shown in full") + assert.Contains(t, out, bundleCLIDigest) + assert.Less(t, strings.Index(out, strings.Repeat("a", 64)), strings.Index(out, bundleCLIDigest), "oldest first") + assert.Contains(t, out, "rollback") + + jsonOut, _, err := runCLI(t, GetSecurityCommand, "json", "bundle", "history") + require.NoError(t, err) + var rows []map[string]any + require.NoError(t, json.Unmarshal([]byte(jsonOut), &rows), jsonOut) + assert.Len(t, rows, 2) +} + +func TestSecurityBundleHistory_Empty(t *testing.T) { + newRESTRecorder(t, map[string]cannedResponse{"GET /api/v1/security/bundle/history": okResp(`[]`)}) + out, _, err := runCLI(t, GetSecurityCommand, "table", "bundle", "history") + require.NoError(t, err) + assert.Contains(t, out, "No bundle activations") +} + +func TestSecurityBundleRollback_SendsRequestAndReportsResult(t *testing.T) { + rec := newRESTRecorder(t, map[string]cannedResponse{"POST /api/v1/security/bundle/rollback": okResp(bundleCLIStatusBody())}) + + out, _, err := runCLI(t, GetSecurityCommand, "table", "bundle", "rollback", "--path", "/tmp/good.json", "--digest", bundleCLIDigest) + require.NoError(t, err) + req := rec.only(t) + assert.Equal(t, http.MethodPost, req.Method) + body := req.jsonBody(t) + assert.Equal(t, "/tmp/good.json", body["path"]) + assert.Equal(t, bundleCLIDigest, body["digest"]) + assert.NotEqual(t, true, body["embedded"]) + assert.Contains(t, out, bundleCLIDigest) +} + +func TestSecurityBundleRollback_EmbeddedSendsEmbedded(t *testing.T) { + rec := newRESTRecorder(t, map[string]cannedResponse{"POST /api/v1/security/bundle/rollback": okResp(bundleCLIStatusBody())}) + _, _, err := runCLI(t, GetSecurityCommand, "table", "bundle", "rollback", "--embedded", "--digest", bundleCLIDigest) + require.NoError(t, err) + body := rec.only(t).jsonBody(t) + assert.Equal(t, true, body["embedded"]) + assert.Equal(t, bundleCLIDigest, body["digest"]) +} + +func TestSecurityBundleRollback_LocalValidationNeverCallsTheDaemon(t *testing.T) { + cases := map[string][]string{ + "neither": {"--digest", bundleCLIDigest}, + "both": {"--embedded", "--path", "/x", "--digest", bundleCLIDigest}, + "short": {"--embedded", "--digest", bundleCLIDigest[:12]}, + "uppercase": {"--embedded", "--digest", strings.ToUpper(bundleCLIDigest)}, + "nodigest": {"--embedded"}, + "nonhex": {"--embedded", "--digest", strings.Repeat("z", 64)}, + "toolong": {"--embedded", "--digest", bundleCLIDigest + "0"}, + "emptypath": {"--path", "", "--digest", bundleCLIDigest}, + "whitespace": {"--embedded", "--digest", " " + bundleCLIDigest[:63]}, + } + for name, args := range cases { + t.Run(name, func(t *testing.T) { + rec := newRESTRecorder(t, map[string]cannedResponse{}) + _, _, err := runCLI(t, GetSecurityCommand, "table", append([]string{"bundle", "rollback"}, args...)...) + require.Error(t, err) + assert.Empty(t, rec.all(), "a malformed rollback must be refused before any request") + }) + } +} + +func TestSecurityBundleRollback_ShortFingerprintMessageSaysFullDigestRequired(t *testing.T) { + newRESTRecorder(t, map[string]cannedResponse{}) + _, _, err := runCLI(t, GetSecurityCommand, "table", "bundle", "rollback", "--embedded", "--digest", bundleCLIDigest[:12]) + require.Error(t, err) + assert.Contains(t, err.Error(), "full digest") + assert.Contains(t, err.Error(), "64") +} + +func TestSecurityBundleRollback_409PrintsTheRejectionReason(t *testing.T) { + refused := `{"success":false,"error":"bundle rollback refused: rollback_target_mismatch: named 0123456789ab, the file holds ffffffffffff","error_code":"rollback_target_mismatch","rejection":{"reason":"rollback_target_mismatch","detail":"named 0123456789ab, the file holds ffffffffffff","at":"2026-10-20T00:00:00Z"}}` + newRESTRecorder(t, map[string]cannedResponse{"POST /api/v1/security/bundle/rollback": refuse(http.StatusConflict, refused)}) + + _, _, err := runCLI(t, GetSecurityCommand, "table", "bundle", "rollback", "--path", "/tmp/x.json", "--digest", bundleCLIDigest) + require.Error(t, err) + assert.Contains(t, err.Error(), "rollback_target_mismatch") + assert.Contains(t, err.Error(), "named 0123456789ab") + assert.Contains(t, err.Error(), "last-known-good", "the operator is told the serving corpus is unchanged") +} + +func TestSecurityBundleRollback_403SurfacesTheDaemonRefusal(t *testing.T) { + newRESTRecorder(t, map[string]cannedResponse{"POST /api/v1/security/bundle/rollback": refuse(http.StatusForbidden, `{"success":false,"error":"operation requires admin access"}`)}) + _, _, err := runCLI(t, GetSecurityCommand, "table", "bundle", "rollback", "--embedded", "--digest", bundleCLIDigest) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires admin access") +} + +func TestSecurityBundleUnpinAndReload(t *testing.T) { + rec := newRESTRecorder(t, map[string]cannedResponse{ + "POST /api/v1/security/bundle/unpin": okResp(bundleCLIStatusBody()), + "POST /api/v1/security/bundle/reload": okResp(bundleCLIStatusBody()), + }) + + out, _, err := runCLI(t, GetSecurityCommand, "table", "bundle", "unpin") + require.NoError(t, err) + assert.Contains(t, out, bundleCLIDigest) + + out, _, err = runCLI(t, GetSecurityCommand, "table", "bundle", "reload") + require.NoError(t, err) + assert.Contains(t, out, bundleCLIDigest) + + reqs := rec.all() + require.Len(t, reqs, 2) + assert.Equal(t, "/api/v1/security/bundle/unpin", reqs[0].Path) + assert.Equal(t, http.MethodPost, reqs[0].Method) + assert.Equal(t, "/api/v1/security/bundle/reload", reqs[1].Path) +} + +func TestSecurityBundleUnpin_JSON(t *testing.T) { + newRESTRecorder(t, map[string]cannedResponse{"POST /api/v1/security/bundle/unpin": okResp(bundleCLIStatusBody())}) + out, _, err := runCLI(t, GetSecurityCommand, "json", "bundle", "unpin") + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &got), out) + assert.Equal(t, bundleCLIDigest, got["full_digest"]) +} + +func TestSecurityBundleStatus_DaemonError(t *testing.T) { + newRESTRecorder(t, map[string]cannedResponse{"GET /api/v1/security/bundle": refuse(http.StatusForbidden, `{"success":false,"error":"Agent tokens cannot read the TPA signature bundle status (administrators only)"}`)}) + _, _, err := runCLI(t, GetSecurityCommand, "table", "bundle") + require.Error(t, err) + assert.Contains(t, err.Error(), "Agent tokens cannot") +} + +// signatureBundleLines is shared by `security overview` and `security bundle`. +func TestSignatureBundleLines_NewFields(t *testing.T) { + var bundle map[string]any + require.NoError(t, json.Unmarshal([]byte(bundleCLIStatusBody()), &bundle)) + lines := signatureBundleLines(map[string]any{"signature_bundle": bundle}) + joined := strings.Join(lines, "\n") + + assert.Contains(t, joined, bundleCLIDigest, "full digest, in full") + assert.Contains(t, joined, "0123456789ab", "short fingerprint stays") + assert.Contains(t, joined, "DEGRADED: operator_bundle_missing") + assert.Contains(t, joined, "publisher") + assert.Contains(t, joined, "downgrade") + assert.Contains(t, joined, "sequence 7 below watermark 42") +} + +func TestSignatureBundleLines_NoDegradedLineWhenHealthy(t *testing.T) { + lines := signatureBundleLines(map[string]any{"signature_bundle": map[string]any{ + "source": "embedded", "runnable_rules": float64(6), "coverage_degraded": false, + "sequence": "5", "authority": "publisher", + }}) + joined := strings.Join(lines, "\n") + assert.NotContains(t, joined, "DEGRADED") + assert.Contains(t, joined, "5") +} + +func TestSignatureBundleLines_UnsignedExternalShowsNoAuthorityNoise(t *testing.T) { + lines := signatureBundleLines(map[string]any{"signature_bundle": map[string]any{ + "source": "file", "path": "/p", "runnable_rules": float64(6), + "coverage_degraded": true, "degraded_reason": "unsigned_external_active", + }}) + joined := strings.Join(lines, "\n") + assert.Contains(t, joined, "DEGRADED: unsigned_external_active") + assert.Contains(t, joined, "unsigned", "an unsigned external bundle says so") +} diff --git a/cmd/mcpproxy/security_cmd.go b/cmd/mcpproxy/security_cmd.go index bb771dadb..1ca89215d 100644 --- a/cmd/mcpproxy/security_cmd.go +++ b/cmd/mcpproxy/security_cmd.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "os" + "regexp" "strings" "time" @@ -57,7 +58,8 @@ Examples: mcpproxy security disable mcp-scan mcpproxy security scan github-server mcpproxy security report github-server - mcpproxy security overview`, + mcpproxy security overview + mcpproxy security bundle`, } securityCmd.AddCommand(newSecurityScannersCmd()) @@ -76,6 +78,7 @@ Examples: securityCmd.AddCommand(newSecurityOverviewCmd()) securityCmd.AddCommand(newSecurityIntegrityCmd()) securityCmd.AddCommand(newSecurityCancelAllCmd()) + securityCmd.AddCommand(newSecurityBundleCmd()) return securityCmd } @@ -2284,38 +2287,99 @@ func signatureBundleLines(overview map[string]interface{}) []string { return nil } - source, _ := bundle["source"].(string) + field := func(label, value string) string { + return fmt.Sprintf(" %-16s %s", label+":", value) + } + + rawSource, _ := bundle["source"].(string) + source := rawSource if path, _ := bundle["path"].(string); path != "" { source = fmt.Sprintf("%s (%s)", source, path) } lines := []string{ " Signature bundle:", - fmt.Sprintf(" Source: %s", source), + field("Source", source), } if version, _ := bundle["bundle_version"].(string); version != "" { - lines = append(lines, fmt.Sprintf(" Version: %s", version)) + lines = append(lines, field("Version", version)) } if generated, _ := bundle["generated_at"].(string); generated != "" { - lines = append(lines, fmt.Sprintf(" Generated: %s", generated)) + if days, ok := bundle["staleness_days"].(float64); ok { + generated = fmt.Sprintf("%s (%d days old)", generated, int(days)) + } + lines = append(lines, field("Generated", generated)) } if fingerprint, _ := bundle["fingerprint"].(string); fingerprint != "" { - lines = append(lines, fmt.Sprintf(" Fingerprint: %s", fingerprint)) - } - lines = append(lines, fmt.Sprintf(" Rules: %s runnable, %s skipped, %s declared-skipped", + lines = append(lines, field("Fingerprint", fingerprint)) + } + if digest, _ := bundle["full_digest"].(string); digest != "" { + // Shown in FULL: it is the only value `security bundle rollback` accepts. + lines = append(lines, field("Full digest", digest)) + } + if seq, _ := bundle["sequence"].(string); seq != "" { + lines = append(lines, field("Sequence", seq)) + } + authority, _ := bundle["authority"].(string) + verified, _ := bundle["signature_verified"].(bool) + switch { + case authority != "": + auth := authority + if epoch, ok := bundle["trust_epoch"].(float64); ok { + auth = fmt.Sprintf("%s (epoch %d)", authority, int(epoch)) + } + lines = append(lines, field("Authority", auth)) + case rawSource != "" && rawSource != "embedded": + lines = append(lines, field("Authority", "none (unsigned external bundle)")) + } + switch { + case verified: + lines = append(lines, field("Signature", "verified")) + case rawSource == "embedded": + lines = append(lines, field("Signature", "binary-trusted (embedded default)")) + case rawSource != "": + lines = append(lines, field("Signature", "unsigned - not verified")) + } + lines = append(lines, field("Rules", fmt.Sprintf("%s runnable, %s skipped, %s declared-skipped", secFormatInt(bundle, "runnable_rules"), secFormatInt(bundle, "skipped_rules"), - secFormatInt(bundle, "declared_skipped"))) + secFormatInt(bundle, "declared_skipped")))) + if degraded, _ := bundle["coverage_degraded"].(bool); degraded { + // scan-mode auto-approval is suspended while coverage is degraded; a + // silent flag is indistinguishable from a quiet install. + reason, _ := bundle["degraded_reason"].(string) + if reason == "" { + reason = "unspecified" + } + lines = append(lines, field("Coverage", "DEGRADED: "+reason+" (scan-mode auto-approval is suspended)")) + } + if pinned, _ := bundle["pinned"].(bool); pinned { + lines = append(lines, field("Pinned", "yes - pinned by an operator rollback (`security bundle unpin` clears it)")) + } + if rollback, _ := bundle["rollback"].(bool); rollback { + lines = append(lines, field("Rollback", "the serving bundle was activated by an explicit rollback")) + } if loadErr, _ := bundle["load_error"].(string); loadErr != "" { // A configured bundle that failed to load keeps the previous corpus // live; say so loudly rather than letting the counts imply all is well. - lines = append(lines, fmt.Sprintf(" load error: %s", loadErr)) + lines = append(lines, field("load error", loadErr)) + } + if rej, ok := bundle["last_rejection"].(map[string]interface{}); ok && len(rej) > 0 { + reason, _ := rej["reason"].(string) + text := reason + if detail, _ := rej["detail"].(string); detail != "" { + text += ": " + detail + } + if at, _ := rej["at"].(string); at != "" { + text += " (" + formatTimestamp(at) + ")" + } + lines = append(lines, field("Last rejection", text)) } if runnable, _ := bundle["runnable_rules"].(float64); runnable == 0 { // Zero runnable rules means offline TPA coverage is OFF. Printed in the // same tone as a healthy count, it read as "fine" — the exact class of // "the runtime is right but the operator is misled" bug #938 is about. - lines = append(lines, " WARNING: no TPA signatures are running — offline scan coverage is OFF") + lines = append(lines, field("WARNING", "no TPA signatures are running — offline scan coverage is OFF")) } return append(lines, "") } @@ -2327,6 +2391,325 @@ func secFormatInt(m map[string]interface{}, key string) string { return "0" } +// --- security bundle (Spec 101 US1: status, history, rollback, unpin, reload) --- + +// bundleFullDigestPattern is the only digest grammar rollback accepts: the +// complete SHA-256 as 64 lowercase hex characters. The daemon enforces the same +// rule; checking here refuses a typo before any request is made. +var bundleFullDigestPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + +func newSecurityBundleCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "bundle", + Short: "Show and manage the offline TPA signature bundle", + Long: `Show which TPA signature corpus this proxy is running and whether it is current +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. + +Examples: + mcpproxy security bundle + mcpproxy security bundle -o json + mcpproxy security bundle history + 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`, + Args: cobra.NoArgs, + RunE: runSecurityBundleStatus, + } + cmd.AddCommand(newSecurityBundleHistoryCmd()) + cmd.AddCommand(newSecurityBundleRollbackCmd()) + cmd.AddCommand(newSecurityBundleUnpinCmd()) + cmd.AddCommand(newSecurityBundleReloadCmd()) + return cmd +} + +func newSecurityBundleHistoryCmd() *cobra.Command { + return &cobra.Command{ + Use: "history", + Short: "List the bundle activation history (oldest first)", + Long: `List every bundle activation on this install, oldest first, with its full +SHA-256 digest. This is the trustworthy place to learn the digest of a +known-good artifact to name in "bundle rollback". + +Examples: + mcpproxy security bundle history + mcpproxy security bundle history -o json`, + Args: cobra.NoArgs, + RunE: runSecurityBundleHistory, + } +} + +func newSecurityBundleRollbackCmd() *cobra.Command { + var ( + embedded bool + path string + digest string + ) + cmd := &cobra.Command{ + Use: "rollback (--embedded | --path PATH) --digest <64-hex>", + Short: "Roll the bundle back to a named artifact (overrides the downgrade watermark)", + Long: `Roll the signature bundle back to an older artifact. This is the one operator +override of the anti-downgrade watermark, so it is authorized by the COMPLETE +SHA-256 digest of the target bytes (64 lowercase hex characters), never by a +path or the 12-character fingerprint. The path only says where to find the +bytes. Find full digests with "mcpproxy security bundle history". + +Give exactly one of --embedded (the corpus compiled into this binary) or +--path. A refused rollback (for example rollback_target_mismatch) leaves the +last-known-good bundle serving and prints the reason. + +Examples: + mcpproxy security bundle rollback --embedded --digest <64-hex> + mcpproxy security bundle rollback --path /var/lib/tpa/known-good.json --digest <64-hex>`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := validateBundleRollbackFlags(embedded, path, cmd.Flags().Changed("path"), digest); err != nil { + return err + } + return runSecurityBundleRollback(embedded, path, digest) + }, + } + cmd.Flags().BoolVar(&embedded, "embedded", false, "Roll back to the bundle compiled into this binary") + cmd.Flags().StringVar(&path, "path", "", "Path to the target bundle file (a hint for where to find the bytes)") + cmd.Flags().StringVar(&digest, "digest", "", "Full SHA-256 of the target as 64 lowercase hex characters (required)") + return cmd +} + +func newSecurityBundleUnpinCmd() *cobra.Command { + return &cobra.Command{ + Use: "unpin", + Short: "Clear the rollback pin and return to normal downgrade protection", + Long: `Clear the rollback pin and re-validate the serving bundle under the normal +watermark. A rolled-back target below the watermark no longer serves; the +install falls back as a restart would. A quiet no-op when nothing is pinned. + +Examples: + mcpproxy security bundle unpin`, + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return runSecurityBundleMutation("/api/v1/security/bundle/unpin", "unpin bundle", "Rollback pin cleared. Now serving:") + }, + } +} + +func newSecurityBundleReloadCmd() *cobra.Command { + return &cobra.Command{ + Use: "reload", + 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. + +Examples: + mcpproxy security bundle reload`, + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return runSecurityBundleMutation("/api/v1/security/bundle/reload", "reload bundle", "Bundle reloaded. 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 { + hasPath := pathChanged || path != "" + if embedded == hasPath { + return newFlagValidationError("give exactly one of --embedded or --path PATH") + } + if hasPath && path == "" { + return newFlagValidationError("--path must not be empty") + } + if !bundleFullDigestPattern.MatchString(digest) { + return newFlagValidationError("--digest must be the full SHA-256 of the target: 64 lowercase hex characters (got %d characters); the 12-character fingerprint is a label, not an authorization - the full digest is required, see `mcpproxy security bundle history`", len(digest)) + } + return nil +} + +// bundleDaemonRequest sends one bundle request and returns the status and body. +func bundleDaemonRequest(method, path string, body []byte, operation string) (int, []byte, error) { + client, _, err := newSecurityCLIClient() + if err != nil { + return 0, nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + resp, err := client.DoRaw(ctx, method, path, body) + if err != nil { + return 0, nil, fmt.Errorf("failed to %s: %w", operation, err) + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return 0, nil, fmt.Errorf("failed to read response: %w", err) + } + return resp.StatusCode, respBody, nil +} + +// bundleData runs a bundle request that must answer 200 and returns the +// envelope's data payload. +func bundleData(method, path string, body []byte, operation string) ([]byte, error) { + status, respBody, err := bundleDaemonRequest(method, path, body, operation) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, parseAPIError(respBody, status, operation) + } + data, err := unwrapAPIResponse(respBody) + if err != nil { + return nil, fmt.Errorf("API error: %w", err) + } + return data, nil +} + +func printBundleStatus(bundle map[string]interface{}) { + for _, line := range signatureBundleLines(map[string]interface{}{"signature_bundle": bundle}) { + fmt.Println(line) + } +} + +func runSecurityBundleStatus(_ *cobra.Command, _ []string) error { + data, err := bundleData(http.MethodGet, "/api/v1/security/bundle", nil, "get bundle status") + if err != nil { + return err + } + var bundle map[string]interface{} + if err := json.Unmarshal(data, &bundle); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + if format := ResolveOutputFormat(); format == "json" || format == "yaml" { + return formatAndPrint(format, bundle) + } + printBundleStatus(bundle) + return nil +} + +func runSecurityBundleHistory(_ *cobra.Command, _ []string) error { + data, err := bundleData(http.MethodGet, "/api/v1/security/bundle/history", nil, "get bundle history") + if err != nil { + return err + } + var rows []map[string]interface{} + if err := json.Unmarshal(data, &rows); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + if rows == nil { + rows = []map[string]interface{}{} + } + if format := ResolveOutputFormat(); format == "json" || format == "yaml" { + return formatAndPrint(format, rows) + } + if len(rows) == 0 { + fmt.Println("No bundle activations recorded.") + return nil + } + // The digest column is last and unpadded: it is the rollback input and must + // never be truncated or wrapped by a column width. + fmt.Printf("%-9s %-10s %-6s %-19s %-9s %-17s %s\n", "SEQUENCE", "AUTHORITY", "EPOCH", "ACTIVATED", "SOURCE", "FLAGS", "FULL DIGEST") + for _, row := range rows { + seq := getMapString(row, "sequence") + if seq == "" { + seq = "-" + } + authority := getMapString(row, "authority") + if authority == "" { + authority = "-" + } + var flags []string + if v, _ := row["signature_verified"].(bool); v { + flags = append(flags, "verified") + } + if v, _ := row["rollback"].(bool); v { + flags = append(flags, "rollback") + } + flagText := strings.Join(flags, ",") + if flagText == "" { + flagText = "-" + } + fmt.Printf("%-9s %-10s %-6s %-19s %-9s %-17s %s\n", + seq, authority, secFormatInt(row, "trust_epoch"), + formatTimestamp(getMapString(row, "activated_at")), + getMapString(row, "source"), flagText, getMapString(row, "full_digest")) + } + return nil +} + +func runSecurityBundleRollback(embedded bool, path, digest string) error { + reqBody, err := json.Marshal(map[string]interface{}{"embedded": embedded, "path": path, "digest": digest}) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + status, respBody, err := bundleDaemonRequest(http.MethodPost, "/api/v1/security/bundle/rollback", reqBody, "roll back bundle") + if err != nil { + return err + } + if status == http.StatusConflict { + return bundleRollbackRefusal(respBody) + } + if status != http.StatusOK { + return parseAPIError(respBody, status, "roll back bundle") + } + data, err := unwrapAPIResponse(respBody) + if err != nil { + return fmt.Errorf("API error: %w", err) + } + return printBundleMutationResult(data, "Rolled back and pinned. Now serving:") +} + +// bundleRollbackRefusal turns the daemon's 409 into an error that names the +// rejection reason, so an operator can tell a wrong digest from a tamper +// attempt from a stale epoch. +func bundleRollbackRefusal(respBody []byte) error { + var resp struct { + Error string `json:"error"` + ErrorCode string `json:"error_code"` + Rejection struct { + Reason string `json:"reason"` + Detail string `json:"detail"` + } `json:"rejection"` + } + if err := json.Unmarshal(respBody, &resp); err != nil { + return cliRefusalError{fmt.Errorf("bundle rollback refused: HTTP 409: %s", string(respBody))} + } + reason := resp.Rejection.Reason + if reason == "" { + reason = resp.ErrorCode + } + msg := "bundle rollback refused (" + reason + ")" + if resp.Rejection.Detail != "" { + msg += ": " + resp.Rejection.Detail + } else if resp.Error != "" { + msg += ": " + resp.Error + } + return cliRefusalError{fmt.Errorf("%s; the last-known-good bundle keeps serving", msg)} +} + +func runSecurityBundleMutation(path, operation, successLine string) error { + data, err := bundleData(http.MethodPost, path, nil, operation) + if err != nil { + return err + } + return printBundleMutationResult(data, successLine) +} + +func printBundleMutationResult(data []byte, successLine string) error { + var bundle map[string]interface{} + if err := json.Unmarshal(data, &bundle); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + if format := ResolveOutputFormat(); format == "json" || format == "yaml" { + return formatAndPrint(format, bundle) + } + fmt.Println(successLine) + printBundleStatus(bundle) + return nil +} + // scannerDisplayStatus normalizes the scanner status to a single vocabulary // shared between table and JSON/YAML output (F-09). // diff --git a/internal/auth/server_ops.go b/internal/auth/server_ops.go index a93fda8f9..5efb5f640 100644 --- a/internal/auth/server_ops.go +++ b/internal/auth/server_ops.go @@ -46,6 +46,10 @@ const ( ServerOpSecurityReject = "security_reject" ServerOpSecretWrite = "secret_write" ServerOpDiagnosticsFix = "diagnostics_fix" + // ServerOpBundleManage covers the Spec 101 TPA bundle mutations (roll back, + // unpin, reload). A rollback is the one operator override of the downgrade + // watermark, so it is an admin-only decision. + ServerOpBundleManage = "bundle_manage" ) // agentDeniedServerOps is the canonical set of server/tool-mutating operations @@ -80,6 +84,7 @@ var agentDeniedServerOps = map[string]struct{}{ ServerOpSecurityReject: {}, ServerOpSecretWrite: {}, ServerOpDiagnosticsFix: {}, + ServerOpBundleManage: {}, } // AgentDeniedServerOp reports whether the named operation is forbidden to agent diff --git a/internal/auth/server_ops_test.go b/internal/auth/server_ops_test.go index cd62fcf45..d57cfc445 100644 --- a/internal/auth/server_ops_test.go +++ b/internal/auth/server_ops_test.go @@ -11,6 +11,7 @@ func TestAgentDeniedServerOp(t *testing.T) { ServerOpConfigToSecret, ServerOpApproveTools, ServerOpBlockTools, ServerOpConfigWrite, ServerOpScan, ServerOpSecurityApprove, ServerOpSecurityReject, ServerOpSecretWrite, ServerOpDiagnosticsFix, + ServerOpBundleManage, } for _, op := range denied { if !AgentDeniedServerOp(op) { diff --git a/internal/httpapi/agent_token_gating_test.go b/internal/httpapi/agent_token_gating_test.go index 11799121e..7195a8868 100644 --- a/internal/httpapi/agent_token_gating_test.go +++ b/internal/httpapi/agent_token_gating_test.go @@ -83,6 +83,9 @@ var mutatingServerRoutes = []struct { {"scanner-remove", http.MethodDelete, "/api/v1/security/scanners/s1"}, {"scan-all", http.MethodPost, "/api/v1/security/scan-all"}, {"cancel-all", http.MethodPost, "/api/v1/security/cancel-all"}, + {"bundle-rollback", http.MethodPost, "/api/v1/security/bundle/rollback"}, + {"bundle-unpin", http.MethodPost, "/api/v1/security/bundle/unpin"}, + {"bundle-reload", http.MethodPost, "/api/v1/security/bundle/reload"}, {"connect-client", http.MethodPost, "/api/v1/connect/claude"}, {"connect-undo", http.MethodPost, "/api/v1/connect/claude/undo"}, {"connect-disconnect", http.MethodDelete, "/api/v1/connect/claude"}, diff --git a/internal/httpapi/scope_route_table_guard_test.go b/internal/httpapi/scope_route_table_guard_test.go index e31e1661a..9a2413773 100644 --- a/internal/httpapi/scope_route_table_guard_test.go +++ b/internal/httpapi/scope_route_table_guard_test.go @@ -63,6 +63,8 @@ var getRouteScopes = map[string]routeScope{ "/api/v1/secrets/config": {scopeRefused, "credential inventory; TestSecretsInventory_DeniedToScopedCaller"}, "/api/v1/security/overview": {scopeRefused, "fleet scan/finding counts; TestSecurityFleetDoors_DeniedToScopedCaller"}, "/api/v1/security/queue": {scopeRefused, "names every queued server; TestSecurityFleetDoors_DeniedToScopedCaller"}, + "/api/v1/security/bundle": {scopeRefused, "TPA bundle digests + last rejection; TestSecurityBundle_AgentTokenRefusedOnEveryRoute"}, + "/api/v1/security/bundle/history": {scopeRefused, "TPA bundle activation history (rollback digests); TestSecurityBundle_AgentTokenRefusedOnEveryRoute"}, "/api/v1/sessions": {scopeRefused, "MCP client/session history; TestSessions_DeniedToAgentTokens"}, "/api/v1/sessions/{id}": {scopeRefused, "MCP client/session history; TestSessions_DeniedToAgentTokens"}, "/api/v1/stats/tokens": {scopeRefused, "deployment-wide token stats; TestTokenStats_DeniedToAgentTokens"}, diff --git a/internal/httpapi/security_bundle.go b/internal/httpapi/security_bundle.go new file mode 100644 index 000000000..175c4d86e --- /dev/null +++ b/internal/httpapi/security_bundle.go @@ -0,0 +1,207 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "io" + "net/http" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/security/scanner" +) + +// Spec 101 TPA signature bundle surface (contracts/bundle-status-surface.md). +// Status and history are admin-read; the three mutations are additionally +// gated by auth.ServerOpBundleManage at the route. + +// bundleAdminDenialMessage is the fixed refusal a scoped caller gets on the +// bundle reads. The bundle status names the artifact digests an operator rolls +// back to, and the last rejection reason — deployment-wide, not per server. +const bundleAdminDenialMessage = "Agent tokens cannot read the TPA signature bundle status (administrators only)" + +// BundleRollbackRefusedResponse is the HTTP 409 body of a well-formed rollback +// that the scanner refused. ErrorCode is the rejection reason (for example +// rollback_target_mismatch); last-known-good keeps serving. +type BundleRollbackRefusedResponse struct { + Success bool `json:"success"` + Error string `json:"error"` + ErrorCode string `json:"error_code"` + Rejection scanner.BundleRejection `json:"rejection"` + RequestID string `json:"request_id,omitempty"` +} + +// bundleRollbackRequest is the POST /security/bundle/rollback body. +type bundleRollbackRequest struct { + // Embedded rolls back to the corpus compiled into this binary. + Embedded bool `json:"embedded"` + // Path is where to find the target bundle (a hint only). + Path string `json:"path"` + // Digest is the full 64-character lowercase hex SHA-256 of the target. + Digest string `json:"digest"` +} + +// isFullBundleDigest reports whether d is 64 lowercase hex characters. +func isFullBundleDigest(d string) bool { + if len(d) != 64 { + return false + } + for i := 0; i < len(d); i++ { + c := d[i] + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} + +// handleGetSecurityBundle godoc +// @Summary Get the TPA signature bundle status +// @Description Reports the offline TPA signature corpus this install is serving: full SHA-256 digest, sequence, authority, trust epoch, signature verification, staleness, degraded coverage with its reason, rollback pin, and the last refused candidate. Admin only. +// @Tags security +// @Produce json +// @Security ApiKeyAuth +// @Security ApiKeyQuery +// @Success 200 {object} contracts.SuccessResponse "Bundle status (data is the BundleInfo object)" +// @Failure 403 {object} contracts.ErrorResponse "Admin credentials required" +// @Failure 501 {object} contracts.ErrorResponse "Security scanner not configured" +// @Router /api/v1/security/bundle [get] +func (s *Server) handleGetSecurityBundle(w http.ResponseWriter, r *http.Request) { + if !s.requireSecurity(w, r) { + return + } + if !s.requireAdminRead(w, r, bundleAdminDenialMessage) { + return + } + s.writeSuccess(w, s.securityController.BundleStatus()) +} + +// handleGetSecurityBundleHistory godoc +// @Summary Get the TPA signature bundle activation history +// @Description Append-only list of bundle activations, oldest first, each with its full SHA-256 digest. It is the trustworthy source of the digest to name in a rollback. Admin only. +// @Tags security +// @Produce json +// @Security ApiKeyAuth +// @Security ApiKeyQuery +// @Success 200 {object} contracts.SuccessResponse "Activation history (data is an array of TPABundleActivation)" +// @Failure 403 {object} contracts.ErrorResponse "Admin credentials required" +// @Failure 500 {object} contracts.ErrorResponse "History could not be read" +// @Failure 501 {object} contracts.ErrorResponse "Security scanner not configured" +// @Router /api/v1/security/bundle/history [get] +func (s *Server) handleGetSecurityBundleHistory(w http.ResponseWriter, r *http.Request) { + if !s.requireSecurity(w, r) { + return + } + if !s.requireAdminRead(w, r, bundleAdminDenialMessage) { + return + } + history, err := s.securityController.BundleHistory() + if err != nil { + s.writeError(w, r, http.StatusInternalServerError, err.Error()) + return + } + if history == nil { + history = []scanner.TPABundleActivation{} + } + s.writeSuccess(w, history) +} + +// handleRollbackSecurityBundle godoc +// @Summary Roll the TPA signature bundle back to a named artifact +// @Description The one operator override of the downgrade watermark. Exactly one of embedded or path must be given, and digest must be the FULL 64-character lowercase hex SHA-256 of the target (the 12-character fingerprint is refused). On success the target serves and is pinned. A refusal (for example rollback_target_mismatch) returns 409 with the rejection reason and last-known-good keeps serving. Admin API key only; agent tokens are refused. +// @Tags security +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Security ApiKeyQuery +// @Param body body bundleRollbackRequest true "Rollback target" +// @Success 200 {object} contracts.SuccessResponse "Updated bundle status" +// @Failure 400 {object} contracts.ErrorResponse "Usage error: both or neither of embedded/path, or digest is not 64 lowercase hex" +// @Failure 403 {object} contracts.ErrorResponse "Admin credentials required" +// @Failure 409 {object} BundleRollbackRefusedResponse "Rollback refused; the response carries the rejection reason" +// @Failure 500 {object} contracts.ErrorResponse "Internal error" +// @Failure 501 {object} contracts.ErrorResponse "Security scanner not configured" +// @Router /api/v1/security/bundle/rollback [post] +func (s *Server) handleRollbackSecurityBundle(w http.ResponseWriter, r *http.Request) { + if !s.requireSecurity(w, r) { + return + } + var req bundleRollbackRequest + if r.Body != nil { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + s.writeError(w, r, http.StatusBadRequest, "Invalid request body") + return + } + } + if !isFullBundleDigest(req.Digest) { + s.writeError(w, r, http.StatusBadRequest, + "the full digest is required: give the 64-character lowercase hex SHA-256 of the target (the 12-character fingerprint is a label, not an authorization); `mcpproxy security bundle history` lists the full digests") + return + } + + info, err := s.securityController.RollbackBundle(scanner.BundleRollbackRequest{ + Embedded: req.Embedded, + Path: req.Path, + Digest: req.Digest, + }) + if err != nil { + var refused *scanner.BundleRollbackRefused + switch { + case errors.As(err, &refused): + s.writeJSON(w, http.StatusConflict, BundleRollbackRefusedResponse{ + Success: false, + Error: refused.Error(), + ErrorCode: refused.Rejection.Reason, + Rejection: refused.Rejection, + RequestID: reqcontext.GetRequestID(r.Context()), + }) + case errors.Is(err, scanner.ErrBundleRollbackUsage): + s.writeError(w, r, http.StatusBadRequest, err.Error()) + default: + s.writeError(w, r, http.StatusInternalServerError, err.Error()) + } + return + } + s.writeSuccess(w, info) +} + +// handleUnpinSecurityBundle godoc +// @Summary Clear the TPA signature bundle rollback pin +// @Description Clears the rollback pin and re-validates the serving bundle under the normal downgrade watermark, so a rolled-back target below the watermark no longer serves. A quiet no-op when nothing is pinned. Admin API key only. +// @Tags security +// @Produce json +// @Security ApiKeyAuth +// @Security ApiKeyQuery +// @Success 200 {object} contracts.SuccessResponse "Updated bundle status" +// @Failure 403 {object} contracts.ErrorResponse "Admin credentials required" +// @Failure 500 {object} contracts.ErrorResponse "The unpin could not be recorded" +// @Failure 501 {object} contracts.ErrorResponse "Security scanner not configured" +// @Router /api/v1/security/bundle/unpin [post] +func (s *Server) handleUnpinSecurityBundle(w http.ResponseWriter, r *http.Request) { + if !s.requireSecurity(w, r) { + return + } + info, err := s.securityController.UnpinBundle() + if err != nil { + s.writeError(w, r, http.StatusInternalServerError, err.Error()) + return + } + s.writeSuccess(w, info) +} + +// handleReloadSecurityBundle godoc +// @Summary Reload the TPA signature bundle +// @Description Re-runs the single-flight bundle pipeline against the configured source and current policy. A refused candidate leaves last-known-good serving and is reported as last_rejection in the returned status. Admin API key only. +// @Tags security +// @Produce json +// @Security ApiKeyAuth +// @Security ApiKeyQuery +// @Success 200 {object} contracts.SuccessResponse "Updated bundle status" +// @Failure 403 {object} contracts.ErrorResponse "Admin credentials required" +// @Failure 501 {object} contracts.ErrorResponse "Security scanner not configured" +// @Router /api/v1/security/bundle/reload [post] +func (s *Server) handleReloadSecurityBundle(w http.ResponseWriter, r *http.Request) { + if !s.requireSecurity(w, r) { + return + } + s.writeSuccess(w, s.securityController.ReloadBundle()) +} diff --git a/internal/httpapi/security_bundle_test.go b/internal/httpapi/security_bundle_test.go new file mode 100644 index 000000000..860256e2f --- /dev/null +++ b/internal/httpapi/security_bundle_test.go @@ -0,0 +1,317 @@ +package httpapi + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/security/scanner" +) + +// bundleMock is the Spec 101 slice of mockSecurityController. +type bundleMock struct { + info scanner.BundleInfo + history []scanner.TPABundleActivation + historyErr error + + rollbackErr error + rollbackCalls int + rollbackReq scanner.BundleRollbackRequest + unpinErr error + unpinCalls int + reloadCalls int +} + +func (m *mockSecurityController) BundleStatus() scanner.BundleInfo { return m.bundle.info } + +func (m *mockSecurityController) BundleHistory() ([]scanner.TPABundleActivation, error) { + return m.bundle.history, m.bundle.historyErr +} + +func (m *mockSecurityController) RollbackBundle(req scanner.BundleRollbackRequest) (scanner.BundleInfo, error) { + m.bundle.rollbackCalls++ + m.bundle.rollbackReq = req + return m.bundle.info, m.bundle.rollbackErr +} + +func (m *mockSecurityController) UnpinBundle() (scanner.BundleInfo, error) { + m.bundle.unpinCalls++ + return m.bundle.info, m.bundle.unpinErr +} + +func (m *mockSecurityController) ReloadBundle() scanner.BundleInfo { + m.bundle.reloadCalls++ + return m.bundle.info +} + +const bundleFullDigest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +func sampleBundleInfo() scanner.BundleInfo { + return scanner.BundleInfo{ + Source: scanner.BundleSourceFile, + Path: "/etc/mcpproxy/bundle.json", + Fingerprint: bundleFullDigest[:12], + FullDigest: bundleFullDigest, + Sequence: "42", + Authority: scanner.AuthorityPublisher, + TrustEpoch: 3, + SignatureVerified: true, + RunnableRules: 10, + CoverageDegraded: true, + DegradedReason: "operator_bundle_missing", + LoadedAt: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), + LastRejection: &scanner.BundleRejection{ + Reason: "downgrade", Detail: "sequence 7 below watermark 42", + At: time.Date(2026, 10, 2, 0, 0, 0, 0, time.UTC), + }, + } +} + +func bundleReq(t *testing.T, srv *Server, method, path, body, key string) *httptest.ResponseRecorder { + t.Helper() + var rdr *bytes.Buffer + if body != "" { + rdr = bytes.NewBufferString(body) + } else { + rdr = &bytes.Buffer{} + } + req := httptest.NewRequest(method, path, rdr) + req.Header.Set("X-API-Key", key) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + return rec +} + +func decodeBundleData(t *testing.T, rec *httptest.ResponseRecorder, into interface{}) { + t.Helper() + var env struct { + Success bool `json:"success"` + Data json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &env), rec.Body.String()) + require.True(t, env.Success, rec.Body.String()) + require.NoError(t, json.Unmarshal(env.Data, into), rec.Body.String()) +} + +func TestSecurityBundle_StatusReturnsInfo(t *testing.T) { + sec := &mockSecurityController{bundle: bundleMock{info: sampleBundleInfo()}} + srv := newTestServerWithSecurity(t, sec) + + rec := bundleReq(t, srv, http.MethodGet, "/api/v1/security/bundle", "", mockControllerAPIKey) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var got scanner.BundleInfo + decodeBundleData(t, rec, &got) + assert.Equal(t, bundleFullDigest, got.FullDigest) + assert.Equal(t, "42", got.Sequence) + assert.True(t, got.CoverageDegraded) + require.NotNil(t, got.LastRejection) + assert.Equal(t, "downgrade", got.LastRejection.Reason) +} + +func TestSecurityBundle_HistoryReturnsActivations(t *testing.T) { + sec := &mockSecurityController{bundle: bundleMock{history: []scanner.TPABundleActivation{ + {FullDigest: bundleFullDigest, Sequence: "41", Source: "file"}, + {FullDigest: strings.Repeat("a", 64), Sequence: "42", Source: "embedded"}, + }}} + srv := newTestServerWithSecurity(t, sec) + + rec := bundleReq(t, srv, http.MethodGet, "/api/v1/security/bundle/history", "", mockControllerAPIKey) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + var got []scanner.TPABundleActivation + decodeBundleData(t, rec, &got) + require.Len(t, got, 2) + assert.Equal(t, "41", got[0].Sequence, "oldest first, as the controller returned it") +} + +func TestSecurityBundle_EmptyHistoryIsAnArray(t *testing.T) { + srv := newTestServerWithSecurity(t, &mockSecurityController{}) + rec := bundleReq(t, srv, http.MethodGet, "/api/v1/security/bundle/history", "", mockControllerAPIKey) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"data":[]`, "an empty history must be [] not null") +} + +func TestSecurityBundle_HistoryErrorIs500(t *testing.T) { + sec := &mockSecurityController{bundle: bundleMock{historyErr: errors.New("db down")}} + srv := newTestServerWithSecurity(t, sec) + rec := bundleReq(t, srv, http.MethodGet, "/api/v1/security/bundle/history", "", mockControllerAPIKey) + assert.Equal(t, http.StatusInternalServerError, rec.Code) +} + +func TestSecurityBundle_RollbackSuccess(t *testing.T) { + sec := &mockSecurityController{bundle: bundleMock{info: sampleBundleInfo()}} + srv := newTestServerWithSecurity(t, sec) + + body := `{"path":"/tmp/known-good.json","digest":"` + bundleFullDigest + `"}` + rec := bundleReq(t, srv, http.MethodPost, "/api/v1/security/bundle/rollback", body, mockControllerAPIKey) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Equal(t, 1, sec.bundle.rollbackCalls) + assert.Equal(t, "/tmp/known-good.json", sec.bundle.rollbackReq.Path) + assert.Equal(t, bundleFullDigest, sec.bundle.rollbackReq.Digest) + var got scanner.BundleInfo + decodeBundleData(t, rec, &got) + assert.Equal(t, bundleFullDigest, got.FullDigest) +} + +func TestSecurityBundle_RollbackEmbeddedSuccess(t *testing.T) { + sec := &mockSecurityController{bundle: bundleMock{info: sampleBundleInfo()}} + srv := newTestServerWithSecurity(t, sec) + body := `{"embedded":true,"digest":"` + bundleFullDigest + `"}` + rec := bundleReq(t, srv, http.MethodPost, "/api/v1/security/bundle/rollback", body, mockControllerAPIKey) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.True(t, sec.bundle.rollbackReq.Embedded) +} + +func TestSecurityBundle_RollbackUsageErrorsAre400(t *testing.T) { + cases := map[string]string{ + "both": `{"embedded":true,"path":"/x","digest":"` + bundleFullDigest + `"}`, + "neither": `{"digest":"` + bundleFullDigest + `"}`, + "short": `{"embedded":true,"digest":"` + bundleFullDigest[:12] + `"}`, + "uppercase": `{"embedded":true,"digest":"` + strings.ToUpper(bundleFullDigest) + `"}`, + "missingDigest": `{"embedded":true}`, + "badJSON": `{not json`, + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + // The real validator lives in the scanner package; the mock mirrors + // the controller contract by returning its usage error. + sec := &mockSecurityController{bundle: bundleMock{rollbackErr: scanner.ErrBundleRollbackUsage}} + srv := newTestServerWithSecurity(t, sec) + rec := bundleReq(t, srv, http.MethodPost, "/api/v1/security/bundle/rollback", body, mockControllerAPIKey) + assert.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + }) + } +} + +func TestSecurityBundle_ShortFingerprintRefusedWithoutReachingController(t *testing.T) { + sec := &mockSecurityController{} + srv := newTestServerWithSecurity(t, sec) + body := `{"embedded":true,"digest":"` + bundleFullDigest[:12] + `"}` + rec := bundleReq(t, srv, http.MethodPost, "/api/v1/security/bundle/rollback", body, mockControllerAPIKey) + require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "full digest is required") + assert.Equal(t, 0, sec.bundle.rollbackCalls, "a bad request must not reach the controller") +} + +func TestSecurityBundle_RollbackRefusedIs409WithReason(t *testing.T) { + rej := scanner.BundleRejection{Reason: "rollback_target_mismatch", Detail: "named 0123456789ab, the file holds ffffffffffff", + At: time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)} + sec := &mockSecurityController{bundle: bundleMock{info: sampleBundleInfo(), rollbackErr: &scanner.BundleRollbackRefused{Rejection: rej}}} + srv := newTestServerWithSecurity(t, sec) + + body := `{"path":"/tmp/x.json","digest":"` + bundleFullDigest + `"}` + rec := bundleReq(t, srv, http.MethodPost, "/api/v1/security/bundle/rollback", body, mockControllerAPIKey) + require.Equal(t, http.StatusConflict, rec.Code, rec.Body.String()) + + var resp struct { + Success bool `json:"success"` + Error string `json:"error"` + ErrorCode string `json:"error_code"` + Rejection scanner.BundleRejection `json:"rejection"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.False(t, resp.Success) + assert.Equal(t, "rollback_target_mismatch", resp.ErrorCode) + assert.Equal(t, "rollback_target_mismatch", resp.Rejection.Reason) + assert.Contains(t, resp.Rejection.Detail, "named 0123456789ab") + assert.Contains(t, resp.Error, "rollback_target_mismatch") +} + +func TestSecurityBundle_RollbackUnexpectedErrorIs500(t *testing.T) { + sec := &mockSecurityController{bundle: bundleMock{rollbackErr: errors.New("boom")}} + srv := newTestServerWithSecurity(t, sec) + body := `{"embedded":true,"digest":"` + bundleFullDigest + `"}` + rec := bundleReq(t, srv, http.MethodPost, "/api/v1/security/bundle/rollback", body, mockControllerAPIKey) + assert.Equal(t, http.StatusInternalServerError, rec.Code) +} + +func TestSecurityBundle_UnpinAndReload(t *testing.T) { + sec := &mockSecurityController{bundle: bundleMock{info: sampleBundleInfo()}} + srv := newTestServerWithSecurity(t, sec) + + rec := bundleReq(t, srv, http.MethodPost, "/api/v1/security/bundle/unpin", "", mockControllerAPIKey) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Equal(t, 1, sec.bundle.unpinCalls) + var got scanner.BundleInfo + decodeBundleData(t, rec, &got) + assert.Equal(t, bundleFullDigest, got.FullDigest) + + rec = bundleReq(t, srv, http.MethodPost, "/api/v1/security/bundle/reload", "", mockControllerAPIKey) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Equal(t, 1, sec.bundle.reloadCalls) + decodeBundleData(t, rec, &got) + assert.Equal(t, "42", got.Sequence) +} + +func TestSecurityBundle_UnpinErrorIs500(t *testing.T) { + sec := &mockSecurityController{bundle: bundleMock{info: sampleBundleInfo(), unpinErr: errors.New("unpin not recorded")}} + srv := newTestServerWithSecurity(t, sec) + rec := bundleReq(t, srv, http.MethodPost, "/api/v1/security/bundle/unpin", "", mockControllerAPIKey) + assert.Equal(t, http.StatusInternalServerError, rec.Code) +} + +func TestSecurityBundle_NotImplementedWithoutController(t *testing.T) { + srv := newTestServerWithSecurity(t, nil) + srv.securityController = nil + for _, rt := range bundleRoutes { + rec := bundleReq(t, srv, rt.method, rt.path, "{}", mockControllerAPIKey) + assert.Equal(t, http.StatusNotImplemented, rec.Code, rt.path) + } +} + +// bundleRoutes is every Spec 101 bundle route; the agent-token tests below +// walk it so a new route cannot be added without being gated. +var bundleRoutes = []struct { + name string + method string + path string + mutation bool +}{ + {"status", http.MethodGet, "/api/v1/security/bundle", false}, + {"history", http.MethodGet, "/api/v1/security/bundle/history", false}, + {"rollback", http.MethodPost, "/api/v1/security/bundle/rollback", true}, + {"unpin", http.MethodPost, "/api/v1/security/bundle/unpin", true}, + {"reload", http.MethodPost, "/api/v1/security/bundle/reload", true}, +} + +func TestSecurityBundle_AgentTokenRefusedOnEveryRoute(t *testing.T) { + for _, rt := range bundleRoutes { + t.Run(rt.name, func(t *testing.T) { + sec := &mockSecurityController{bundle: bundleMock{info: sampleBundleInfo()}} + srv, token := scopeRound10Server(t, sec) + + body := "" + if rt.name == "rollback" { + body = `{"embedded":true,"digest":"` + bundleFullDigest + `"}` + } + rec := bundleReq(t, srv, rt.method, rt.path, body, token) + assert.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String()) + assert.NotContains(t, rec.Body.String(), bundleFullDigest, "a refused caller must learn nothing") + assert.Zero(t, sec.bundle.rollbackCalls+sec.bundle.unpinCalls+sec.bundle.reloadCalls, + "a refused caller must not reach the mutation") + + // Positive control: the same route and fixture serve an admin. + adminRec := bundleReq(t, srv, rt.method, rt.path, body, scopeAdminAPIKey) + assert.Equal(t, http.StatusOK, adminRec.Code, adminRec.Body.String()) + }) + } +} + +func TestSecurityBundle_UnauthenticatedRefused(t *testing.T) { + srv := newTestServerWithSecurity(t, &mockSecurityController{}) + for _, rt := range bundleRoutes { + rec := bundleReq(t, srv, rt.method, rt.path, "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, rt.path) + } +} diff --git a/internal/httpapi/security_scanner.go b/internal/httpapi/security_scanner.go index 2a18e4eb5..ac0934827 100644 --- a/internal/httpapi/security_scanner.go +++ b/internal/httpapi/security_scanner.go @@ -56,6 +56,14 @@ type SecurityController interface { // Scan history ListScanHistory(ctx context.Context) ([]scanner.ScanJobSummary, error) GetScanReportByJobID(ctx context.Context, jobID string) (*scanner.AggregatedReport, error) + + // TPA signature bundle (Spec 101): status, activation history and the + // operator mutations (explicit rollback, unpin, reload). + BundleStatus() scanner.BundleInfo + BundleHistory() ([]scanner.TPABundleActivation, error) + RollbackBundle(req scanner.BundleRollbackRequest) (scanner.BundleInfo, error) + UnpinBundle() (scanner.BundleInfo, error) + ReloadBundle() scanner.BundleInfo } // SetSecurityController configures the security scanner controller on the server. diff --git a/internal/httpapi/security_scanner_test.go b/internal/httpapi/security_scanner_test.go index fb8f503a3..a3bb9d3ec 100644 --- a/internal/httpapi/security_scanner_test.go +++ b/internal/httpapi/security_scanner_test.go @@ -51,6 +51,9 @@ type mockSecurityController struct { reviewedExpected map[string]string reviewedInc int64 + // Spec 101 TPA bundle surface (methods in security_bundle_test.go). + bundle bundleMock + // What the last ConfigureScanner call carried. configuredEnv map[string]string configuredImage string diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index a2613bd6c..0efd0c9b8 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -1314,6 +1314,15 @@ func (s *Server) setupRoutes() { r.Get("/scanners/{id}/status", s.handleGetScannerStatus) r.Get("/overview", s.handleSecurityOverview) + // Spec 101 TPA signature bundle. Status and history are admin-read; + // the mutations (a rollback overrides the downgrade watermark) are + // refused to agent tokens. + r.Get("/bundle", s.handleGetSecurityBundle) + r.Get("/bundle/history", s.handleGetSecurityBundleHistory) + r.Post("/bundle/rollback", s.requireServerOp(auth.ServerOpBundleManage, s.handleRollbackSecurityBundle)) + r.Post("/bundle/unpin", s.requireServerOp(auth.ServerOpBundleManage, s.handleUnpinSecurityBundle)) + r.Post("/bundle/reload", s.requireServerOp(auth.ServerOpBundleManage, s.handleReloadSecurityBundle)) + // Legacy routes (backwards compatibility) r.Post("/scanners/install", s.requireServerOp(auth.ServerOpScan, s.handleInstallScanner)) r.Delete("/scanners/{id}", s.requireServerOp(auth.ServerOpScan, s.handleRemoveScanner)) diff --git a/oas/docs.go b/oas/docs.go index bd4fa99c0..5f0d5ddb2 100644 --- a/oas/docs.go +++ b/oas/docs.go @@ -6,10 +6,10 @@ import "github.com/swaggo/swag/v2" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, - "components": {"schemas":{"auth.CredentialIssuer":{"properties":{"actor_kind":{"type":"string"},"actor_name":{"type":"string"},"surface":{"type":"string"}},"type":"object"},"config.AuditLogConfig":{"description":"AuditLog configures the Spec 107 edition-neutral audit sink\n(internal/audit). nil means \"use the per-edition/per-transport\ndefault\" (EffectiveAuditLog); restart-pinned (bound at sink\nconstruction). See audit_log.go.","properties":{"compress":{"type":"boolean"},"enabled":{"type":"boolean"},"max_age_days":{"type":"integer"},"max_backups":{"type":"integer"},"max_size_mb":{"type":"integer"},"path":{"type":"string"},"stdout":{"type":"boolean"}},"type":"object"},"config.ConcurrencyDefaults":{"description":"ServerConcurrencyDefaults is scope (b) of FR-020: the blanket per-server\ndefault set inherited by every server that does not override a setting.\nAbsent (the default) = no per-server limiting unless a server configures\nit explicitly. File/API-configured only — no env scheme (FR-022).","properties":{"max_concurrent_requests":{"type":"integer"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"}},"type":"object"},"config.Config":{"properties":{"activity_cleanup_interval_min":{"description":"Background cleanup interval in minutes (default: 60)","type":"integer"},"activity_max_records":{"description":"Max records before pruning (default: 100000)","type":"integer"},"activity_max_response_size":{"description":"Response truncation limit in bytes (default: 65536)","type":"integer"},"activity_max_size_mb":{"description":"ActivityMaxSizeMB caps the total activity-log size in MB before the\noldest records are pruned. Omit the key for the 256MB default; set it to\n0 to disable the size cap.","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"advertise_upstream_servers":{"description":"AdvertiseUpstreamServers controls whether MCP clients are told the\nnames of the upstream servers they can reach — in the initialize\ninstructions and the retrieve_tools description — so agents use the\nproxied tools instead of falling back to shell CLIs. Names are always\nfiltered to the caller's profile and agent-token scope. Default true;\nset false to keep server names out of client context. Read live.","type":"boolean"},"aggregate_upstream_prompts":{"description":"AggregateUpstreamPrompts, when true, aggregates every connected upstream\nserver's advertised MCP prompts into mcpproxy's own prompts/list\n(exposed as \"\u003cserver\u003e__\u003cprompt\u003e\"). OFF by default: users are safe by\ndefault and opt in deliberately. EnablePrompts still governs the built-in\nprompts + the prompts capability; this flag gates ONLY the upstream\naggregation performed by RefreshPrompts. Hot-reloadable.","type":"boolean"},"allow_private_registry_fetch":{"description":"AllowPrivateRegistryFetch opts out of the registry SSRF guard (MCP-1076,\nCWE-918). By default (false) registry fetches refuse any host that is — or\nresolves to — a non-routable address (loopback, RFC1918/CGNAT private,\nlink-local incl. the 169.254.169.254 cloud-metadata endpoint), so a\nmalicious or typo'd registry source cannot turn the daemon into a\nrequest-forgery vector against internal services.\n\nThis opt-out is BLANKET (all-or-nothing): setting it true disables the\nguard for EVERY non-routable range at once — loopback, RFC1918/CGNAT\nprivate, link-local AND the 169.254.169.254 cloud-metadata endpoint. There\nis no way to allow only loopback; enabling it for a localhost dev registry\nalso re-opens the cloud-metadata SSRF vector. Set true ONLY when you\nintentionally run a trusted registry mirror on an internal/private address,\nideally on a host with no cloud-metadata exposure. The change takes effect\nonly on daemon (re)start or config reload.","type":"boolean"},"allow_server_add":{"type":"boolean"},"allow_server_remove":{"type":"boolean"},"anonymous_profile":{"description":"AnonymousProfile confines every caller whose request authenticates as\ncredential kind \"anonymous\" (no credential, or an unrecognised\nnon-agent token accepted by the require_mcp_auth:false back-compat\nbranch) to the named profile (Spec 108 FR-008). Empty (default) means\nunconfined, legacy anonymous behaviour. A name that does not match any\nconfigured profile resolves anonymous callers to deny-all and is\nreported as a validation warning (data-model.md §1).","type":"string"},"api_key":{"description":"Security settings","type":"string"},"audit_log":{"$ref":"#/components/schemas/config.AuditLogConfig"},"call_tool_timeout":{"type":"string"},"check_server_repo":{"description":"Repository detection settings","type":"boolean"},"code_execution_max_parallel":{"description":"Default concurrency for call_tools() batches (1-32, default: 8)","type":"integer"},"code_execution_max_tool_calls":{"description":"Max tool calls per execution (0 = unlimited, default: 0)","type":"integer"},"code_execution_pool_size":{"description":"JavaScript runtime pool size (default: 10)","type":"integer"},"code_execution_timeout_ms":{"description":"Timeout in milliseconds (default: 120000, max: 600000)","type":"integer"},"data_dir":{"type":"string"},"debug_search":{"type":"boolean"},"direct_tool_response_mode":{"description":"DirectToolResponseMode selects the serialization of the DIRECT\nenumeration surface (Spec 102). Valid values: \"\" (= full), \"full\"\n(default: today's schema-bearing entries), \"deferred\" (description +\ncompact signature, with a minimal permissive input schema; upstream\ninputSchema and outputSchema are stripped and recovered on demand via\ndescribe_tool).\n\nDeliberately NOT an extension of tool_response_mode: reusing that axis\nwould silently change /mcp/all output for every deployment already\nrunning compact, which FR-015 forbids. Serialization-only — it never\nchanges WHICH tools are listed, only how (FR-008). Hot-reloadable.","type":"string"},"disable_management":{"type":"boolean"},"docker_isolation":{"$ref":"#/components/schemas/config.DockerIsolationConfig"},"docker_recovery":{"$ref":"#/components/schemas/config.DockerRecoveryConfig"},"enable_code_execution":{"description":"Code execution settings","type":"boolean"},"enable_prompts":{"description":"Prompts settings","type":"boolean"},"enable_socket":{"description":"Enable Unix socket/named pipe for local IPC (default: true)","type":"boolean"},"enable_tray":{"description":"Deprecated: EnableTray is unused and has no runtime effect. Kept for backward compatibility.","type":"boolean"},"environment":{"$ref":"#/components/schemas/secureenv.EnvConfig"},"features":{"$ref":"#/components/schemas/config.FeatureFlags"},"forward_client_headers":{"description":"ForwardClientHeaders is the global switch for client header forwarding\n(Spec 112): copying allowlisted headers from the inbound /mcp request into\nthe upstream tools/call request. nil (absent) means enabled; because every\nper-server forward_headers allowlist is empty by default, the default\nforwards nothing. false disables forwarding for every server. Read via\nIsClientHeaderForwardingEnabled(); MCPPROXY_FORWARD_CLIENT_HEADERS\noverrides it for the process. Distinct from ForwardedHeaders/trusted_proxies\n(Spec 107), which trusts inbound X-Forwarded-*.","type":"boolean"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned stdio upstream servers (MCP-2769). OFF by\ndefault: proxy URLs commonly embed credentials (http://user:pass@proxy), so\nforwarding them to every upstream is a credential-leak risk. When enabled,\nvalues are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"health_check_interval":{"description":"Discovery \u0026 health-check cadence (spec 074, #608). Both are *Duration\ntri-state pointers: nil = inherit the built-in default; a pointer to 0s =\nthe loop is disabled; a positive value = that interval. Defaults live only\nin the resolvers (ResolveHealthCheckInterval / ResolveToolDiscoveryInterval)\nso an unset key behaves exactly as before this feature (SC-005). Validated\nin Validate(): health-check ∈ {0} ∪ [5s,1h]; tool-discovery ∈ {0} ∪ [30s,24h].","type":"string"},"http_idle_timeout":{"description":"HTTPIdleTimeout caps how long an idle keep-alive connection is kept open.\nUnset = 180s. \"0s\" removes the dedicated idle deadline, but net/http then\nfalls back to ReadTimeout — idle is fully unbounded only when\nhttp_read_timeout is also \"0s\". Requires a restart.","type":"string"},"http_read_timeout":{"description":"HTTPReadTimeout caps how long reading a whole request (headers + body)\nmay take. Unset = 120s; \"0s\" disables it. Requires a restart.","type":"string"},"http_write_timeout":{"description":"HTTPWriteTimeout caps how long producing a whole response may take on\nnon-streaming endpoints (REST, Web UI, health). Unset = 120s; \"0s\"\ndisables it globally. MCP and SSE /events routes are exempt by design.","type":"string"},"init_timeout":{"description":"InitTimeout is the global default deadline for an upstream's MCP\n` + "`" + `initialize` + "`" + ` handshake (MCP-3322 / GH #760). *Duration tri-state: nil =\ninherit the built-in 30s default; a positive value = that deadline. A\nper-server InitTimeout overrides this. Resolved by ResolveInitTimeout;\nvalidated to {0} ∪ [1s, 30m] in Validate(). Servers doing legitimate\nfirst-run warmup (cache/index build) before answering ` + "`" + `initialize` + "`" + ` can\nraise this so they are not killed mid-startup.","type":"string"},"instructions":{"description":"Instructions text returned in the MCP initialize response to guide AI agents.\nWhen empty, a built-in default is used that explains retrieve_tools workflow.","type":"string"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"max_concurrent_requests":{"description":"Concurrency limits (spec 093, GH #955). Scope (a) of FR-020: the GLOBAL\nAGGREGATE limiter — one proxy-wide cap on concurrently running upstream\ntool calls, with its own bounded wait queue. Tri-state pointers: absent =\nthe limiter does not exist (default, zero behavior change); an explicit 0\nmax also disables it; positive = that cap. This scope is NEVER a\nper-server inheritance source — per-server values come from\nServerConcurrencyDefaults / the per-server overrides — but a server's\neffective concurrency is bounded by BOTH its own limiter and this one.\nResolved by ResolveGlobalConcurrency; hot-reloadable; overridable via\nMCPPROXY_MAX_CONCURRENT_REQUESTS / _QUEUE_SIZE / _QUEUE_TIMEOUT (FR-022).","type":"integer"},"max_result_size_chars":{"description":"MaxResultSizeChars is advertised on every tool as\n` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; it raises Claude Code's\ninline-response ceiling from 50k to up to 500k chars. Omit the key for\nthe 500000 default; set it to 0 to disable the annotation.","type":"integer"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"observability":{"$ref":"#/components/schemas/config.ObservabilityConfig"},"output_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"output_validation":{"$ref":"#/components/schemas/config.OutputValidationConfig"},"profiles":{"description":"Profiles are optional named, server-scoped views exposed at /mcp/p/\u003cname\u003e\n(Spec 057). Absent/empty is fully supported — /mcp is unchanged and configs\nwithout this key serialize byte-identically (SC-004).","items":{"$ref":"#/components/schemas/config.ProfileConfig"},"type":"array","uniqueItems":false},"quarantine_enabled":{"description":"QuarantineEnabled controls whether quarantine is active. It gates two\nthings together:\n 1. Server-level auto-quarantine for newly added servers (issue #370).\n When true, servers added via the upstream_servers MCP tool or the\n REST API default to quarantined=true; when false, they default to\n quarantined=false. Explicit per-request values always win.\n 2. Tool-level quarantine (Spec 032): per-tool SHA-256 approval of\n tool descriptions/schemas.\nWhen nil (default), quarantine is enabled (secure by default). Set to\nexplicit false to opt out of both. Per-server SkipQuarantine still\napplies for the tool-level check on individual servers.","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"registries_locked":{"description":"RegistriesLocked is an enterprise stub knob (MCP-866): when true, runtime\nadditions of custom registries (e.g. ` + "`" + `registry add-source` + "`" + `, the REST/MCP\nadd-source surface) are rejected so an administrator can pin the discovery\nsources. Built-in defaults are unaffected. Documented but otherwise inert\nbeyond the add-source rejection.","type":"boolean"},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"reveal_secret_headers":{"description":"RevealSecretHeaders, when true, disables the redaction of the\nsecret-bearing server fields — sensitive header values (Authorization,\nX-API-Key, Cookie, …), env-var secrets, and URL query credentials — in\nresponses from the ` + "`" + `upstream_servers` + "`" + ` MCP tool, the ` + "`" + `/api/v1/servers` + "`" + `\nREST API, and the SSE event stream. It also lets URL secrets echoed\ninto last_error / health.detail through unscrubbed.\n\nDefault false — sensitive values are surfaced masked as\n` + "`" + `••••\u003clast2\u003e (\u003cN\u003e chars)` + "`" + ` (error strings use ` + "`" + `***REDACTED***` + "`" + `) so an\nMCP agent cannot read Bearer tokens / API keys / URL secrets out of\nanother upstream's config (PR #425, issue #872). ${env:…}/${keyring:…}\nreferences are labels, not secrets, and pass through unchanged.\n\nThe Web UI / macOS tray edit forms work without seeing the real\nvalues: PATCH /api/v1/servers/{id} deep-merges (omitted keys are\npreserved, see ` + "`" + `headers_remove` + "`" + ` / ` + "`" + `env_remove` + "`" + ` for explicit\ndeletes), so clients compute a diff and only send the keys that\nactually changed. Redacted-but-unchanged values never round-trip\n— the backend keeps the real string. Set this to true if a\ndownstream tool genuinely needs raw values in the response.","type":"boolean"},"routing_mode":{"description":"Routing mode (Spec 031): how MCP tools are exposed to clients\nValid values: \"retrieve_tools\" (default), \"direct\", \"code_execution\"","type":"string"},"security":{"$ref":"#/components/schemas/config.SecurityConfig"},"sensitive_data_detection":{"$ref":"#/components/schemas/config.SensitiveDataDetectionConfig"},"server_concurrency_defaults":{"$ref":"#/components/schemas/config.ConcurrencyDefaults"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_call_max_records_per_server":{"description":"Calls retained per server (default: 1000)","type":"integer"},"tool_call_max_response_size":{"description":"Bounds for the per-server tool-call history behind GET /api/v1/tool-calls\n(#1176). It is a recent-debugging window, not an audit log — the activity\nlog is the durable record — and it kept every upstream response whole,\nper server, forever. A non-positive value means \"use the default\", not\n\"disable\": this store must never be unbounded again, so there is\ndeliberately no off switch.","type":"integer"},"tool_discovery_interval":{"type":"string"},"tool_response_limit":{"type":"integer"},"tool_response_mode":{"description":"Tool response mode (Spec 085): how retrieve_tools serializes results.\nValid values: \"\" (= full), \"full\" (default: today's schema-bearing\nentries), \"compact\" (signature + first-sentence entries). Orthogonal to\nrouting_mode — routing_mode selects the tool SURFACE, this selects the\nSERIALIZATION within the retrieve_tools surface. Serialization-only: it\nnever affects the query, ranking, or result set. Hot-reloadable.","type":"string"},"tool_response_session_risk_warning":{"description":"ToolResponseSessionRiskWarning controls whether the prose ` + "`" + `warning` + "`" + ` field\nis included in the ` + "`" + `session_risk` + "`" + ` object returned by ` + "`" + `retrieve_tools` + "`" + `.\nThe structured fields (level, lethal_trifecta, has_open_world_tools, etc.)\nare always included. Default: false (quiet for LLM clients) — see issue #406.\nMost tools lack annotations, so the MCP-spec defaults treat them as fully\npermissive across all three risk axes, which makes the prose warning fire\non almost every call and wastes tokens.","type":"boolean"},"tools_limit":{"type":"integer"},"toon_min_savings_pct":{"description":"ToonMinSavingsPct is the minimum byte-savings percentage (validated\n1-90; 0/unset → 15) the complete TOON emission (marker + hint + body)\nmust achieve over the exact passthrough emission for adaptive mode to\nencode a block. Byte savings approximate token savings for the tabular\npayload class; the spec-083 profiler reports true token deltas.\nGlobal-only (no per-server override, FR-001).","type":"integer"},"toon_output":{"description":"ToonOutput selects the TOON encoding mode for call_tool_* result text\nblocks (spec 084): \"off\" (default — responses byte-identical to\npre-feature behavior), \"adaptive\" (encode only tabular-uniform payloads\nthat beat compact JSON by ToonMinSavingsPct), or \"always\"\n(benchmark/debug only — encodes every JSON-parseable block and can\nINCREASE token cost). Per-server override: ServerConfig.ToonOutput.\nResolved by ResolveToonOutput; hot-reloadable.","type":"string"},"top_k":{"description":"Deprecated: TopK is superseded by ToolsLimit and has no runtime effect. Kept for backward compatibility.","type":"integer"},"tray_endpoint":{"description":"Tray endpoint override (unix:// or npipe://)","type":"string"},"trusted_hosts":{"description":"TrustedHosts lists non-loopback Host header values accepted on loopback\nlisteners (GH #898). DNS-rebinding protection rejects requests whose Host\nheader is not a loopback address when mcpproxy listens on loopback; a\nreverse proxy (nginx → 127.0.0.1) forwarding the public domain in Host\ntrips it. Entries are hostnames, case-insensitive; an entry without a\nport matches any port, with a port it must match exactly; a leading dot\n(\".example.com\") is a subdomain wildcard. The single entry \"*\" disables\nHost and Origin validation entirely. The same list also validates the\nOrigin header when present (MCP spec DNS-rebinding defense). Empty\n(default) keeps full protection. Env override: MCPPROXY_TRUSTED_HOSTS\n(comma-separated).","items":{"type":"string"},"type":"array","uniqueItems":false},"trusted_proxies":{"description":"TrustedProxies lists the CIDRs or IP addresses whose X-Forwarded-For /\nX-Real-IP / X-Forwarded-Proto / X-Forwarded-Host headers are believed\n(Spec 107 FR-027). Empty (default) trusts nobody. Edition-neutral, live\n(hot-reloadable). Env override: MCPPROXY_TRUSTED_PROXIES (comma-separated).\nThe one reader is ForwardedHeaders; validation is validateTrustedProxies.","items":{"type":"string"},"type":"array","uniqueItems":false},"update_check":{"$ref":"#/components/schemas/config.UpdateCheckConfig"}},"type":"object"},"config.CustomPattern":{"properties":{"category":{"description":"Category (defaults to \"custom\")","type":"string"},"keywords":{"description":"Keywords to match (mutually exclusive with Regex)","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Unique identifier for this pattern","type":"string"},"regex":{"description":"Regex pattern (mutually exclusive with Keywords)","type":"string"},"severity":{"description":"Risk level: critical, high, medium, low","type":"string"}},"type":"object"},"config.DeepScanConfig":{"description":"DeepScan is the opt-in \"deep scan\" layer (Spec 077 US3). It subsumes the\ndeprecated top-level scanner_fetch_package_source / scanner_disable_no_new_privileges\nkeys (migrated on load) and gates the heavy Docker-based scanners + source\nextraction. Disabled by default (FR-006): only the deterministic in-process\nbaseline scanner runs. A deep-scan failure NEVER changes the baseline verdict\n(FR-007/FR-008).","properties":{"disable_no_new_privileges":{"description":"DisableNoNewPrivileges, when true, omits the ` + "`" + `--security-opt\nno-new-privileges` + "`" + ` flag from scanner container runs (snap-docker/AppArmor\nescape hatch). Absorbs the deprecated top-level\nscanner_disable_no_new_privileges. Default false.","type":"boolean"},"enabled":{"description":"Enabled is the master opt-in for the heavy layer (FR-006). Default false.","type":"boolean"},"fetch_package_source":{"description":"FetchPackageSource controls whether the scanner fetches the PUBLISHED\nsource of package-runner servers (npx/uvx) — without executing it — when\nno local source is available. Absorbs the deprecated top-level\nscanner_fetch_package_source. Default (nil) is ENABLED within deep scan.","type":"boolean"},"scanners":{"description":"Scanners optionally restricts which deep scanners may run under the\numbrella (by scanner id). Empty ⇒ all enabled deep scanners are eligible.","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.DockerIsolationConfig":{"description":"Docker isolation settings","properties":{"cpu_limit":{"description":"CPU limit for containers","type":"string"},"default_images":{"additionalProperties":{"type":"string"},"description":"Map of runtime type to Docker image","type":"object"},"enable_cache_volume":{"description":"Mount shared cache volumes for faster restarts (default: true)","type":"boolean"},"enabled":{"description":"Global enable/disable for Docker isolation (legacy; superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments","items":{"type":"string"},"type":"array","uniqueItems":false},"log_driver":{"description":"Docker log driver (default: json-file)","type":"string"},"log_max_files":{"description":"Maximum number of log files (default: 3)","type":"string"},"log_max_size":{"description":"Maximum size of log files (default: 100m)","type":"string"},"memory_limit":{"description":"Memory limit for containers","type":"string"},"mode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"network_mode":{"description":"Docker network mode (default: bridge)","type":"string"},"registry":{"description":"Custom registry (defaults to docker.io)","type":"string"},"timeout":{"description":"Container startup timeout","type":"string"}},"type":"object"},"config.DockerRecoveryConfig":{"description":"Docker recovery settings","properties":{"enabled":{"description":"Enable Docker recovery monitoring (default: true)","type":"boolean"},"max_retries":{"description":"Maximum retry attempts (0 = unlimited)","type":"integer"},"notify_on_failure":{"description":"Show notification on recovery failure (default: true)","type":"boolean"},"notify_on_retry":{"description":"Show notification on each retry (default: false)","type":"boolean"},"notify_on_start":{"description":"Show notification when recovery starts (default: true)","type":"boolean"},"notify_on_success":{"description":"Show notification on successful recovery (default: true)","type":"boolean"},"persistent_state":{"description":"Save recovery state across restarts (default: true)","type":"boolean"}},"type":"object"},"config.FeatureFlags":{"description":"Deprecated: Features flags are unused and have no runtime effect. Kept for backward compatibility.","properties":{"enable_async_storage":{"type":"boolean"},"enable_caching":{"type":"boolean"},"enable_contract_tests":{"type":"boolean"},"enable_debug_logging":{"description":"Development features","type":"boolean"},"enable_docker_isolation":{"type":"boolean"},"enable_event_bus":{"type":"boolean"},"enable_health_checks":{"type":"boolean"},"enable_metrics":{"type":"boolean"},"enable_oauth":{"description":"Security features","type":"boolean"},"enable_observability":{"description":"Observability features","type":"boolean"},"enable_quarantine":{"type":"boolean"},"enable_runtime":{"description":"Runtime features","type":"boolean"},"enable_search":{"description":"Storage features","type":"boolean"},"enable_sse":{"type":"boolean"},"enable_tracing":{"type":"boolean"},"enable_tray":{"type":"boolean"},"enable_web_ui":{"description":"UI features","type":"boolean"}},"type":"object"},"config.IntentDeclarationConfig":{"description":"Intent declaration settings (Spec 018)","properties":{"strict_server_validation":{"description":"StrictServerValidation controls whether server annotation mismatches\ncause rejection (true) or just warnings (false).\nDefault: true (reject mismatches)","type":"boolean"}},"type":"object"},"config.IsolationConfig":{"description":"Per-server isolation settings","properties":{"enabled":{"description":"Enable Docker isolation for this server (nil = inherit global; legacy, superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments for this server","items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"description":"Custom Docker image (overrides default)","type":"string"},"log_driver":{"description":"Docker log driver override for this server","type":"string"},"log_max_files":{"description":"Maximum number of log files override","type":"string"},"log_max_size":{"description":"Maximum size of log files override","type":"string"},"mode":{"$ref":"#/components/schemas/config.IsolationMode"},"network_mode":{"description":"Custom network mode for this server","type":"string"},"working_dir":{"description":"Custom working directory in container","type":"string"}},"type":"object"},"config.IsolationMode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"config.LogConfig":{"description":"Logging configuration","properties":{"compress":{"type":"boolean"},"enable_console":{"type":"boolean"},"enable_file":{"type":"boolean"},"filename":{"type":"string"},"json_format":{"type":"boolean"},"level":{"type":"string"},"log_dir":{"description":"Custom log directory","type":"string"},"max_age":{"description":"days","type":"integer"},"max_backups":{"description":"number of backup files","type":"integer"},"max_size":{"description":"MB","type":"integer"}},"type":"object"},"config.MetricsExporterConfig":{"description":"Metrics gates the Prometheus /metrics scrape endpoint (MCP-32). Disabled\nby default — operators opt in for k8s/enterprise deployments.","properties":{"enabled":{"description":"Enabled exposes /metrics on the existing HTTP listener when true.\nThe endpoint is admin-authenticated (SEC-07): scrapers must present the\nglobal API key, via X-API-Key or an Authorization: Bearer header.","type":"boolean"}},"type":"object"},"config.OAuthConfig":{"description":"OAuth configuration (keep even when empty to signal OAuth requirement)","properties":{"auth_server_metadata_url":{"type":"string"},"authorization_endpoint":{"description":"Spec 113-b: optional per-server discovery overrides. Each is an absolute\nhttps URL (http only on a loopback host) with no query, fragment or\nuserinfo; validated at write time only (ValidateOAuthEndpointOverrides).\nThe endpoint overrides rewrite the matching fields of the authorization\nserver metadata mcp-go reads; AuthServerMetadataURL replaces discovery.","type":"string"},"callback_base_url":{"type":"string"},"client_id":{"type":"string"},"client_secret":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"description":"Additional OAuth parameters (e.g., RFC 8707 resource)","type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_uri":{"type":"string"},"registration_endpoint":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"token_endpoint":{"type":"string"}},"type":"object"},"config.ObservabilityConfig":{"description":"Observability settings (Spec 069): usage aggregate cache/persistence cadence.","properties":{"metrics":{"$ref":"#/components/schemas/config.MetricsExporterConfig"},"tracing":{"$ref":"#/components/schemas/config.TracingExporterConfig"},"usage_cache_ttl":{"description":"UsageCacheTTL bounds the freshness of the usage endpoint's read cache for\nwide windows (FR-005). Default 5s.","type":"string"},"usage_persist_interval":{"description":"UsagePersistInterval is how often the actor-owned usage aggregate snapshot\nis flushed to storage. Default 30s.","type":"string"}},"type":"object"},"config.OutputSanitisationConfig":{"description":"Output sanitisation settings (Spec 054 Track B)","properties":{"max_redactions":{"description":"cap on redactions per response; default 100","type":"integer"},"response_action":{"description":"\"spotlight\" | \"redact\" | \"block\"; default \"spotlight\"","type":"string"},"spotlight_untrusted":{"description":"wrap untrusted output in spotlight markers; default true","type":"boolean"},"strip_classes":{"description":"classes to strip: ansi/c0c1/bidi/zero_width","items":{"type":"string"},"type":"array","uniqueItems":false},"strip_control_chars":{"description":"strip control-character classes; default false","type":"boolean"}},"type":"object"},"config.OutputValidationConfig":{"description":"Output-schema validation settings (Spec 056)","properties":{"max_bytes":{"description":"structured payload byte cap; default 5\u003c\u003c20","type":"integer"},"max_depth":{"description":"nesting depth cap; default 64","type":"integer"},"missing_structured_content":{"description":"\"allow\" | \"block\"; default \"allow\"","type":"string"},"mode":{"description":"\"off\" | \"warn\" | \"strict\"; default \"warn\"","type":"string"}},"type":"object"},"config.ProfileConfig":{"properties":{"code_execution":{"description":"CodeExecution: nil = inherit the global enable_code_execution gate;\nnon-nil narrows it (a profile can only narrow, never widen, FR-006).","type":"boolean"},"description":{"description":"\u003c= 500 chars (FR-001)","type":"string"},"management_tools":{"description":"ManagementTools: nil = legacy/inherit (FR-016); non-nil sets the\nprofile-level visibility of upstream_servers/quarantine_security.","type":"boolean"},"max_tier":{"description":"MaxTier caps the tier a tool may run at under this profile: \"\" (no\ncap) | \"read\" | \"write\" | \"destructive\" (FR-001).","type":"string"},"name":{"description":"slug (Spec 057 rules unchanged)","type":"string"},"servers":{"description":"references to mcpServers[].name","items":{"type":"string"},"type":"array","uniqueItems":false},"switchable_to":{"description":"SwitchableTo is the list of profile names a session under this\nprofile may ` + "`" + `set_profile` + "`" + ` into (FR-022). nil = legacy/none (research\nD6); a non-nil EMPTY list is an explicit \"none\" and must round-trip as\n` + "`" + `[]` + "`" + `, never be dropped by omitempty — hence the pointer (see the\nMarshalJSON note below, and profiles_v3_test.go's switchable_to round\ntrip case).","items":{"type":"string"},"type":"array","uniqueItems":false},"title":{"description":"display only, \u003c= 80 chars (FR-001)","type":"string"},"tools":{"$ref":"#/components/schemas/config.ProfileToolRules"},"unannotated":{"description":"Unannotated is this profile's handling of a tool whose effective\nannotations carry no tier hint: \"\" (unset, see EffectiveUnannotated) |\n\"deny\" | \"as_write\" | \"as_read\" (FR-001, FR-003).","type":"string"}},"type":"object"},"config.ProfileToolRules":{"description":"Tools holds the allow/deny/classify rule set (FR-001, FR-004, FR-005).","properties":{"allow":{"items":{"type":"string"},"type":"array","uniqueItems":false},"classify":{"additionalProperties":{"type":"string"},"type":"object"},"deny":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag for this registry (MCP-866):\nRegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom\nfor user-added registries. It is authoritatively (re)computed by the\nregistries merge from whether the ID is a shipped default — a user cannot\nclaim \"official\" by writing it into their config.","type":"string"},"requires_key":{"description":"RequiresKey marks a registry that needs an API key to be queried. When\ntrue and no key is configured, the registry is skipped/marked unavailable\nrather than failing the whole search (FR-008).","type":"boolean"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"auto_baseline_scan":{"description":"AutoBaselineScan is the kill-switch for the AUTOMATIC, informational\nPass-1 baseline scan: the free in-process TPA scan mcpproxy runs for every\nnewly admitted server (any trust mode) and, once per installation, over\npre-existing servers that have never been scanned.\n\nInformational ONLY: the resulting verdict populates the security badge and\nthe scan summary, and NEVER gates quarantine or approval. The\ntrust_mode:\"scan\" admission gate is a separate path and is unaffected by\nthis flag.\n\nDefault (nil) is ENABLED. Set to false to suppress every automatic scan\n(manual scans keep working). Env override: MCPPROXY_AUTO_BASELINE_SCAN,\nwhich wins over this field on every path.","type":"boolean"},"deep_scan":{"$ref":"#/components/schemas/config.DeepScanConfig"},"integrity_check_interval":{"type":"string"},"integrity_check_on_restart":{"type":"boolean"},"runtime_read_only":{"type":"boolean"},"runtime_tmpfs_size":{"type":"string"},"scan_timeout_default":{"type":"string"},"scanner_disable_no_new_privileges":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.DisableNoNewPrivileges\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.IsDisableNoNewPrivileges. Cleared after migration.\n\nScannerDisableNoNewPrivileges, when true, omits the\n` + "`" + `--security-opt no-new-privileges` + "`" + ` flag from scanner container runs.\n\nBackground: snap-installed Docker on Ubuntu confines dockerd under the\n` + "`" + `snap.docker.dockerd` + "`" + ` AppArmor profile. When runc tries to transition\nthe container into the inner ` + "`" + `docker-default` + "`" + ` profile to exec the\nentrypoint, AppArmor refuses the transition because NO_NEW_PRIVS\nforbids privilege/profile changes on exec — the result is EPERM\n(\"operation not permitted\") and every scanner fails immediately.\n\nSet this to true ONLY on hosts hitting that incompatibility. Scanner\ncontainers still run with read-only rootfs, tmpfs /tmp, no-network by\ndefault, and read-only source mounts, so the marginal isolation loss\nis small. The preferred fix remains replacing snap docker with a\ndistro-packaged docker.","type":"boolean"},"scanner_fetch_package_source":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.FetchPackageSource\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.EffectiveFetchPackageSource. Cleared after migration.\n\nScannerFetchPackageSource controls whether the scanner fetches the\nPUBLISHED source of package-runner servers (npx/uvx) — without executing\nit — when no local source is available (no Docker container, no local\npackage cache, no working_dir). This is the primary quarantine/scan\ntarget: a quarantined-on-add server is never run locally, so without this\nthe scan degrades to tool-definitions-only (no real source-level\nanalysis). See MCP-2206.\n\nFetching uses ` + "`" + `npm pack --ignore-scripts` + "`" + ` (npm) and ` + "`" + `uv pip download` + "`" + ` /\n` + "`" + `pip download` + "`" + ` with ` + "`" + `--only-binary=:all:` + "`" + ` (Python), which only download +\nunpack archives and NEVER run install, build, or setup.py — a scanner must\nnot execute the untrusted code it is scanning. The Python\n` + "`" + `--only-binary=:all:` + "`" + ` flag is required because downloading an sdist would\ninvoke its build backend (setup.py); packages with no wheel fall back to\ntool-definitions-only instead. Extraction is hardened against path\ntraversal and decompression bombs.\n\nDefault (nil) is ENABLED. Set to false on air-gapped deployments to\nforbid the scanner's network egress; such servers then fall back to the\ntool-definitions-only scan with no regression.","type":"boolean"},"scanner_registry_url":{"type":"string"},"tpa_bundle_path":{"description":"TPABundlePath is the filesystem path to the tpa-db scanner-bundle.json\nthe offline TPA scanner runs (spec 086 FR-019: the signature-DB location\nMUST be configuration-driven, not hardcoded). Empty (the default) runs the\ncorpus embedded in this build.\n\nEnv override: MCPPROXY_TPA_BUNDLE_PATH. Hot-reloadable — the path is\nre-read on every config.reloaded event via\nscanner.Service.ApplySecurityConfig, so a corpus refresh needs no restart.\nA configured bundle that fails to read/parse/version-check/compile is\nREFUSED and the previously active corpus stays live (fail-closed, never\nfail-empty); the reason is logged and surfaced in the security overview's\nsignature_bundle.load_error.","type":"string"}},"type":"object"},"config.SensitiveDataDetectionConfig":{"description":"Sensitive data detection settings (Spec 026)","properties":{"categories":{"additionalProperties":{"type":"boolean"},"description":"Enable/disable specific detection categories","type":"object"},"custom_patterns":{"description":"User-defined detection patterns","items":{"$ref":"#/components/schemas/config.CustomPattern"},"type":"array","uniqueItems":false},"enabled":{"description":"Enable sensitive data detection (default: true)","type":"boolean"},"entropy_threshold":{"description":"Shannon entropy threshold for high-entropy detection (default: 4.5)","type":"number"},"max_payload_size_kb":{"description":"Max size to scan before truncating (default: 1024)","type":"integer"},"scan_requests":{"description":"Scan tool call arguments (default: true)","type":"boolean"},"scan_responses":{"description":"Scan tool responses (default: true)","type":"boolean"},"sensitive_keywords":{"description":"Keywords to flag","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ServerConfig":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve tool\nchanges/additions (disabling per-server rug-pull protection). Supersedes\nskip_quarantine. MCP-2930 only ACCEPTS, persists, and migrates this flag — it\nis NOT yet consulted at runtime; auto-approval is still governed by\nSkipQuarantine until the trust-baseline behavior change (MCP-2931) migrates the\nruntime consumers onto it.\nTri-state pointer (mirrors QuarantineEnabled): nil = unset (inherit/migrate\nfrom legacy skip_quarantine), explicit true/false = honored as-is so an\nexplicit auto_approve_tool_changes:false overrides a legacy skip_quarantine:true.\nRead via IsAutoApproveToolChanges().","type":"boolean"},"command":{"type":"string"},"created":{"type":"string"},"disabled_tools":{"description":"Denylist: these tools are hidden; mutually exclusive with enabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"enabled":{"type":"boolean"},"enabled_tools":{"description":"Allowlist: only these tools are exposed; mutually exclusive with disabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"env":{"additionalProperties":{"type":"string"},"type":"object"},"expose_prompts":{"description":"ExposePrompts overrides whether this server's advertised MCP prompts are\naggregated into mcpproxy's prompts/list. nil (default) inherits the\ndefault-aggregate behavior (included if the server advertises\nCapabilities.Prompts); false excludes it regardless of capability.","type":"boolean"},"forward_headers":{"description":"ForwardHeaders lists inbound MCP-client header NAMES (never values) that\nare copied into this server's upstream tools/call requests (Spec 112).\nExact names, case-insensitive, at most 32, no wildcards. Empty forwards\nnothing. Only HTTP-based transports (http, streamable-http) forward; the\ndeny list (Authorization, Host, hop-by-hop, ...) is enforced at runtime\nregardless of what is configured here.","items":{"type":"string"},"type":"array","uniqueItems":false},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"health_check_interval":{"description":"Per-server discovery \u0026 health-check overrides (spec 074). Same *Duration\ntri-state as the global keys: nil = inherit the global value (or default),\npointer to 0s = disabled for this server, positive = that interval.\nHealthCheckInterval is fully wired into the per-server health loop;\nToolDiscoveryInterval is accepted/validated and round-trips for\nforward-compat, but the periodic index sweep is governed by the global\ncadence in this iteration (see spec 074 plan §C).","type":"string"},"init_timeout":{"description":"InitTimeout overrides the global init_timeout for this server's MCP\n` + "`" + `initialize` + "`" + ` handshake deadline (MCP-3322 / GH #760). *Duration tri-state:\nnil = inherit the global value (or 30s default), positive = that deadline.\nResolved by Config.ResolveInitTimeout; validated to {0} ∪ [1s, 30m]. Raise\nthis for upstreams that do legitimate first-run warmup (e.g. caching many\nchannels/users) before responding to ` + "`" + `initialize` + "`" + `.","type":"string"},"isolation":{"$ref":"#/components/schemas/config.IsolationConfig"},"launcher_wait_timeout":{"description":"LauncherWaitTimeout caps how long mcpproxy will wait for a locally-launched\nHTTP/SSE upstream's URL to become reachable after Spawn(). Only consulted\nwhen the server is configured with both Command and an HTTP/SSE URL — i.e.,\nmcpproxy starts the process AND connects via network. Stdio servers ignore\nthis field. Zero or unset → 30s default.","type":"string"},"max_concurrent_requests":{"description":"Per-server concurrency overrides — scope (c) of FR-020 (spec 093, #955).\nTri-state per setting, exactly like HealthCheckInterval: absent = inherit\nthe per-server default set (server_concurrency_defaults), explicit 0 =\ndisable that setting for this server (0 max = no per-server limiter at\nall; 0 queue_size = no pending capacity, shed immediately at the cap),\npositive = override. The global aggregate limiter is never inherited from\nhere — it applies on top, so effective concurrency is min(per-server,\nglobal). Resolved by Config.ResolveServerConcurrency.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/config.OAuthConfig"},"protocol":{"description":"stdio, http, sse, streamable-http, auto","type":"string"},"quarantined":{"description":"Security quarantine status","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets a disconnected server","type":"boolean"},"shared":{"description":"Server edition: shared with all users","type":"boolean"},"skip_quarantine":{"description":"SkipQuarantine is DEPRECATED (MCP-2930): use AutoApproveToolChanges instead.\nKept for back-compat parsing; on config load a legacy skip_quarantine:true is\nmigrated to auto_approve_tool_changes:true only when the new field is unset\n(see normalizeServerQuarantineFlags).","type":"boolean"},"source_registry_id":{"description":"SourceRegistryID records which registry this server was added from (empty\nfor manually-configured servers). MCP-866: surfaced in the approval /\nquarantine view so a reviewer can see a server's origin.","type":"string"},"source_registry_provenance":{"description":"SourceRegistryProvenance records the source registry's provenance at add\ntime (RegistryProvenanceOfficial / RegistryProvenanceCustom). It is purely\ninformational (MCP-1072) — surfaced so a reviewer can see a server's origin\n— and no longer gates quarantine or skip_quarantine.","type":"string"},"tool_discovery_interval":{"type":"string"},"toon_output":{"description":"ToonOutput overrides the global toon_output mode for this server's\ntools (spec 084, FR-001). Plain string, not a pointer: \"\"/absent =\ninherit the global value; \"off\"|\"adaptive\"|\"always\" = override (\"off\"\nis the explicit force-off). Resolved by Config.ResolveToonOutput.","type":"string"},"trust_mode":{"description":"TrustMode is the per-server trust tier: auto|scan|manual. Supersedes\nauto_approve_tool_changes (spec 086). An empty value is derived from the\nlegacy fields at load via normalizeServerQuarantineFlags; the single\nresolution point is EffectiveTrustMode(), which treats an empty or\nunrecognized value as manual (secure by default). Read via\nEffectiveTrustMode(), never the raw string.","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"working_dir":{"description":"Working directory for stdio servers","type":"string"}},"type":"object"},"config.TLSConfig":{"description":"TLS configuration","properties":{"certs_dir":{"description":"Directory for certificates","type":"string"},"enabled":{"description":"Enable HTTPS","type":"boolean"},"hsts":{"description":"Enable HTTP Strict Transport Security","type":"boolean"},"require_client_cert":{"description":"Enable mTLS","type":"boolean"}},"type":"object"},"config.TelemetryConfig":{"description":"Telemetry settings (Spec 036)","properties":{"anonymous_id":{"description":"Auto-generated UUIDv4","type":"string"},"anonymous_id_created_at":{"description":"Spec 042 (Tier 2) additions — all default-zero, all backwards-compatible.","type":"string"},"enabled":{"description":"Default: true (opt-out)","type":"boolean"},"endpoint":{"description":"Override for testing","type":"string"},"last_reported_version":{"description":"Upgrade funnel","type":"string"},"last_startup_outcome":{"description":"success|port_conflict|db_locked|...","type":"string"},"notice_shown":{"description":"First-run notice flag","type":"boolean"}},"type":"object"},"config.TokenizerConfig":{"description":"Tokenizer configuration for token counting","properties":{"default_model":{"description":"Default model for tokenization (e.g., \"gpt-4\")","type":"string"},"enabled":{"description":"Enable token counting","type":"boolean"},"encoding":{"description":"Default encoding (e.g., \"cl100k_base\")","type":"string"}},"type":"object"},"config.TracingExporterConfig":{"description":"Tracing gates the OpenTelemetry OTLP trace exporter (MCP-32). Disabled by\ndefault.","properties":{"enabled":{"description":"Enabled turns on OTLP trace export for tool calls and upstream hops.","type":"boolean"},"endpoint":{"description":"Endpoint is the collector address as host:port (no scheme), e.g.\n\"localhost:4318\" for http or \"localhost:4317\" for grpc.","type":"string"},"protocol":{"description":"Protocol selects the OTLP transport: \"http\" or \"grpc\".","type":"string"},"sample_rate":{"description":"SampleRate is the head-based trace sampling ratio in [0,1]. Default 0.1.\nOmit the key for the 0.1 default; set it to 0 to sample nothing.","type":"number"}},"type":"object"},"config.UpdateCheckConfig":{"description":"Update-check settings (Spec 079 FR-012): config-file control of the\nbackground upgrade-awareness checker (internal/updatecheck). nil =\nenabled on the stable channel (existing default behavior). The existing\nenvironment switches keep working and WIN over these keys (FR-014):\nMCPPROXY_DISABLE_AUTO_UPDATE=true force-disables even when\nenabled=true, and MCPPROXY_ALLOW_PRERELEASE_UPDATES=true force-selects\nthe rc channel even when channel=stable.","properties":{"channel":{"description":"Channel selects which releases are offered as updates: \"stable\"\n(default; prereleases never offered) or \"rc\" (prereleases included).\nEmpty resolves to stable. Validated in ValidateDetailed.\n\nNOTE: for a RELEASED build the running binary's own version is\nauthoritative and overrides this field — a stable build is never\noffered an RC (even with channel=rc), and an RC build always tracks the\nrc channel. This field only takes effect on dev/unstamped builds. See\ninternal/updatecheck.Checker.IncludePrereleases.","type":"string"},"enabled":{"description":"Enabled gates all update checking. Tri-state: nil/absent = enabled\n(default true, matching pre-079 behavior). When false, no network\ncheck is performed and no upgrade nudge appears on any surface\n(FR-015) — /api/v1/info omits the update object entirely.","type":"boolean"}},"type":"object"},"configimport.FailedServer":{"properties":{"details":{"type":"string"},"error":{"type":"string"},"name":{"type":"string"}},"type":"object"},"configimport.ImportSummary":{"properties":{"failed":{"type":"integer"},"imported":{"type":"integer"},"skipped":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"configimport.SkippedServer":{"properties":{"name":{"type":"string"},"reason":{"description":"\"already_exists\", \"filtered_out\", \"invalid_name\", \"self_reference\"","type":"string"}},"type":"object"},"connect.ConnectResult":{"description":"The full result; its action mirrors the top-level one","properties":{"action":{"description":"\"created\", \"updated\", \"already_exists\", \"removed\", \"not_found\"","type":"string"},"backup_path":{"type":"string"},"client":{"type":"string"},"config_path":{"type":"string"},"credential":{"description":"Credential is the masked client credential the write embedded\n(` + "`" + `mcp_cli_••••` + "`" + `); the real secret is never returned (FR-024). Empty for a\nkeyless entry, a disconnect and every refusal.","type":"string"},"credential_revoke_error":{"description":"CredentialRevokeError is set when a disconnect removed the entry but\nrevoking the client credential failed. The disconnect still succeeded;\nthe credential is still active and ` + "`" + `mcpproxy client forget \u003cid\u003e` + "`" + `\nretries the revoke.","type":"string"},"credential_revoked":{"description":"CredentialRevoked names the credential that was revoked: by an undo,\nbecause the restored config no longer holds it (plan D16), or by a\ndisconnect, which cuts the client off (a later undo of that disconnect\ndoes not bring the credential back).","type":"string"},"display_path":{"description":"DisplayPath is ConfigPath with the home directory shortened to \"~\"\n(FR-037). Populated for every result whose ConfigPath is known.","type":"string"},"keyless":{"description":"Keyless is true when the entry was written with no credential.","type":"boolean"},"message":{"type":"string"},"mode":{"type":"string"},"profile":{"description":"Profile and Mode are the binding of the credential this write minted or\nkept. Profile \"\" means the built-in All servers scope.","type":"string"},"reload_hint":{"description":"ReloadHint is this client's instruction for making the write take\neffect (FR-037/FR-042), e.g. \"Restart Cursor to load MCPProxy\". Empty\nfor an unknown client.","type":"string"},"rotation":{"description":"Rotation is \"finalized\" when the write replaced an active credential's\nsecret (staged rotation, FR-021a), empty otherwise.","type":"string"},"server_name":{"type":"string"},"success":{"type":"boolean"},"token_name":{"description":"TokenName is the client credential's token name (` + "`" + `client-\u003cid\u003e` + "`" + `).","type":"string"}},"type":"object"},"contracts.APIResponse":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ActivityDetailResponse":{"properties":{"activity":{"$ref":"#/components/schemas/contracts.ActivityRecord"}},"type":"object"},"contracts.ActivityListResponse":{"properties":{"activities":{"items":{"$ref":"#/components/schemas/contracts.ActivityRecord"},"type":"array","uniqueItems":false},"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.ActivityPerServer":{"properties":{"calls":{"description":"Calls counted per storage.CountsAsCall","type":"integer"},"errors":{"description":"Of those calls, how many failed","type":"integer"},"last_call_at":{"description":"LastCallAt is RFC3339, or \"\" if the server had no call in the period\n(PerServer only lists servers that did, so this is always set).","type":"string"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityRecord":{"properties":{"agent_name":{"description":"Agent token name when auth_type is \"agent\"","type":"string"},"arguments":{"description":"Tool call arguments","type":"object"},"auth_type":{"description":"\"admin\", \"agent\", \"user\" or \"admin_user\"; empty without an auth context or on another caller's row for a scoped caller","type":"string"},"block_reason":{"description":"Typed cause of a profile policy refusal","type":"string"},"client_id":{"description":"Client id from the client-credential binding","type":"string"},"client_name":{"description":"Self-reported clientInfo.name (advisory)","type":"string"},"detection_types":{"description":"List of detection types found","items":{"type":"string"},"type":"array","uniqueItems":false},"duration_ms":{"description":"Execution duration in milliseconds","type":"integer"},"error_class":{"description":"Call-error taxonomy (Spec 113-c FR-045): what failed and whose fault it\nis. Absent on success and on records written before Spec 113.","type":"string"},"error_message":{"description":"Error details if status is \"error\"","type":"string"},"fault_domain":{"description":"upstream|proxy|client","type":"string"},"has_sensitive_data":{"description":"Sensitive data detection fields (Spec 026)","type":"boolean"},"id":{"description":"Unique identifier (ULID format)","type":"string"},"max_severity":{"description":"Highest severity level detected (critical, high, medium, low)","type":"string"},"metadata":{"description":"Additional context-specific data","type":"object"},"parent_id":{"description":"Correlation id of the parent call (the code_execution whose sandbox issued this sub-call)","type":"string"},"profile":{"description":"Scope attribution (Spec 108 FR-029): the profile, client and token in\neffect when the call ran. Stamped at write time, never rewritten. For a\nscoped (non-admin) caller the profile/profile_source/client_id/token_name\nof a row it did not make are blanked (binding disclosure is admin-only);\nclient_name is self-reported and stays. Absent on records written before\nSpec 108 and on records with no MCP/REST request context.","type":"string"},"profile_source":{"description":"pin|binding|url|session|anonymous|none","type":"string"},"request_bytes":{"description":"Byte sizes measured pre-truncation, mirroring storage.ActivityRecord\n(Spec 069 A1). They are the only cost signal a bodies-off export carries:\nwith payloads suppressed there is no text left to measure, so a consumer\naccounting for a record it cannot read has nothing else to go on. They are\nbyte LENGTHS, not token counts — the basis for an explicit estimate, never\na measured figure (spec 103, contracts/replay-input.md).\n\nZero means UNKNOWN, not free: legacy records predate the measurement and\ncode-execution sub-calls record both as zero. Hence omitempty — an absent\nkey tells a consumer to fall to exclusion accounting, whereas a present\nzero would read as a costless call and silently understate the workload.","type":"integer"},"request_id":{"description":"HTTP request ID for correlation","type":"string"},"response":{"description":"Tool response (potentially truncated)","type":"string"},"response_bytes":{"description":"Raw upstream response size in bytes before truncation","type":"integer"},"response_truncated":{"description":"True if response was truncated","type":"boolean"},"server_name":{"description":"Name of upstream MCP server","type":"string"},"session_id":{"description":"MCP transport session ID (regenerated on every reconnect)","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\", \"rejected\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"token_name":{"description":"Agent/client token name","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"},"upstream_http_status":{"description":"Upstream HTTP status when known","type":"integer"},"work_session_id":{"description":"Spec 082: one client, one project, across reconnects","type":"string"}},"type":"object"},"contracts.ActivitySource":{"description":"How activity was triggered: \"mcp\", \"cli\", \"api\"","type":"string","x-enum-varnames":["ActivitySourceMCP","ActivitySourceCLI","ActivitySourceAPI"]},"contracts.ActivitySummaryResponse":{"properties":{"blocked_count":{"description":"Count of blocked activities","type":"integer"},"call_count":{"description":"CallCount is how many of those records are CALLS THE USER MADE, as\ndefined once in storage.CountsAsCall and shared with the usage aggregate\nbehind the Usage tab (audit finding F1, #1046). TotalCount answers \"how\nmany rows does the Activity Log have\"; CallCount answers \"how many calls\nwere there\". They are different questions — quarantine auto-approvals,\nsystem start, security scans and management chatter are events, not calls\n— and printing either one under the other's label is how the same instance\ncame to report 51 calls on one screen and 19 on another.","type":"integer"},"call_error_count":{"description":"CallErrorCount is the failures within CallCount, so an error RATE computed\nfrom this response has one denominator. It is not ErrorCount: a policy\nblock is a failed call but carries status \"blocked\", and a shed call is an\nerror in neither sense because it never ran.","type":"integer"},"end_time":{"description":"End of the period (RFC3339)","type":"string"},"error_count":{"description":"Count of error activities","type":"integer"},"other_count":{"description":"OtherCount is every record whose status is outside the four-value\nvocabulary above, so that\n\n\tsuccess + error + blocked + rejected + other == total\n\nholds by construction. The status field is a CLOSED vocabulary for tool\ncalls, but the activity log is wider than tool calls: a quarantine change\nstores its ACTION there (\"approved\", \"auto_approved\"), a policy decision\nstores its DECISION (\"allow\"). Those rows were counted in the total and in\nnone of the four buckets, so the Activity Log's own status tiles summed to\nless than the denominator printed beside them — 15+4+0+0 under a \"42\"\n(audit finding F2, #1046). The residual now has a name and a tile.","type":"integer"},"per_server":{"description":"PerServer covers EVERY server with at least one call in the period\n(unlike TopServers, which is capped at 5 and carries no error counts).\nSpec 109 FR-013: the server-card stats line and the macOS Servers rows\nread this — one ` + "`" + `GET /activity/summary` + "`" + ` response per page load — rather\nthan issuing a per-server activity query each. Computed in the same\ncounting pass as the totals above, from the same CountsAsCall/\nIsManagementBuiltin definitions TopServers already uses.","items":{"$ref":"#/components/schemas/contracts.ActivityPerServer"},"type":"array","uniqueItems":false},"period":{"description":"Time period (1h, 24h, 7d, 30d)","type":"string"},"rejected_count":{"description":"RejectedCount is the number of calls shed by a concurrency limiter before\nthey reached an upstream (spec 093). Counted separately from errors: it is\nproxy backpressure, not an upstream fault, and it is the signal an\noperator right-sizes max_concurrent_requests against.","type":"integer"},"start_time":{"description":"Start of the period (RFC3339)","type":"string"},"success_count":{"description":"Count of successful activities","type":"integer"},"top_servers":{"description":"Top servers by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopServer"},"type":"array","uniqueItems":false},"top_tools":{"description":"Top tools by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopTool"},"type":"array","uniqueItems":false},"total_count":{"description":"Total activity count","type":"integer"}},"type":"object"},"contracts.ActivityTopServer":{"properties":{"count":{"description":"Activity count","type":"integer"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityTopTool":{"properties":{"count":{"description":"Activity count","type":"integer"},"server":{"description":"Server name","type":"string"},"tool":{"description":"Tool name","type":"string"}},"type":"object"},"contracts.ActivityType":{"description":"Type of activity","type":"string","x-enum-varnames":["ActivityTypeToolCall","ActivityTypePolicyDecision","ActivityTypeQuarantineChange","ActivityTypeServerChange"]},"contracts.AddFromRegistryRequest":{"properties":{"enabled":{"description":"defaults to true when nil","type":"boolean"},"env":{"additionalProperties":{"type":"string"},"description":"overrides + required-input values","type":"object"},"name":{"description":"optional name override","type":"string"}},"type":"object"},"contracts.AddRegistrySourceRequest":{"properties":{"id":{"description":"derived from the host when empty","type":"string"},"name":{"description":"defaults to the id","type":"string"},"protocol":{"description":"defaults to modelcontextprotocol/registry","type":"string"},"url":{"description":"required https registry URL","type":"string"}},"type":"object"},"contracts.AttentionFix":{"properties":{"label":{"type":"string"},"target":{"type":"string"},"verb":{"type":"string"}},"type":"object"},"contracts.AttentionItem":{"properties":{"detail":{"type":"string"},"fix":{"$ref":"#/components/schemas/contracts.AttentionFix"},"id":{"description":"kind:type:subject[:state]","type":"string"},"kind":{"type":"string"},"rank":{"type":"integer"},"since":{"type":"string"},"subject":{"$ref":"#/components/schemas/contracts.AttentionSubject"},"summary":{"type":"string"}},"type":"object"},"contracts.AttentionSubject":{"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"description":"server|tool|client|setting","type":"string"}},"type":"object"},"contracts.ConfigApplyResult":{"properties":{"applied_immediately":{"type":"boolean"},"changed_fields":{"items":{"type":"string"},"type":"array","uniqueItems":false},"requires_restart":{"type":"boolean"},"restart_reason":{"type":"string"},"success":{"type":"boolean"},"validation_errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DCRStatus":{"properties":{"attempted":{"type":"boolean"},"error":{"type":"string"},"status_code":{"type":"integer"},"success":{"type":"boolean"}},"type":"object"},"contracts.DeepScanDescriptor":{"description":"DeepScan reports the opt-in \"deep scan\" layer status (Spec 077 US3),\nSEPARATELY from the baseline verdict above. Always emitted on a computed\nsummary — when deep scan is off (the default) it reports enabled=false\nplus any enabled-but-skipped Docker scanners. It never influences Status.","properties":{"available":{"type":"boolean"},"enabled":{"type":"boolean"},"ran":{"type":"boolean"},"scanners_failed":{"items":{"$ref":"#/components/schemas/contracts.DeepScanScannerFailure"},"type":"array","uniqueItems":false},"skipped_scanners":{"description":"SkippedScanners lists Docker scanners the user enabled that are skipped\nbecause security.deep_scan.enabled is false (informational).","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DeepScanScannerFailure":{"properties":{"id":{"type":"string"},"reason":{"type":"string"}},"type":"object"},"contracts.DeprecatedConfigWarning":{"properties":{"field":{"type":"string"},"message":{"type":"string"},"replacement":{"type":"string"}},"type":"object"},"contracts.Diagnostic":{"description":"Spec 044 — structured diagnostic error and stable error code. Both\nare populated when the server is in a failed state and the error\nhas been classified by internal/diagnostics. Healthy servers omit\nthese fields.","properties":{"cause":{"type":"string"},"code":{"type":"string"},"detected_at":{"type":"string"},"docs_url":{"type":"string"},"fix_steps":{"items":{"$ref":"#/components/schemas/contracts.DiagnosticFixStep"},"type":"array","uniqueItems":false},"severity":{"type":"string"},"user_message":{"type":"string"}},"type":"object"},"contracts.DiagnosticFixStep":{"properties":{"command":{"type":"string"},"destructive":{"type":"boolean"},"fixer_key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"},"url":{"type":"string"}},"type":"object"},"contracts.Diagnostics":{"properties":{"deprecated_configs":{"description":"Deprecated config fields found","items":{"$ref":"#/components/schemas/contracts.DeprecatedConfigWarning"},"type":"array","uniqueItems":false},"docker_status":{"$ref":"#/components/schemas/contracts.DockerStatus"},"missing_secrets":{"description":"Renamed to avoid conflict","items":{"$ref":"#/components/schemas/contracts.MissingSecretInfo"},"type":"array","uniqueItems":false},"oauth_issues":{"description":"OAuth parameter mismatches","items":{"$ref":"#/components/schemas/contracts.OAuthIssue"},"type":"array","uniqueItems":false},"oauth_required":{"items":{"$ref":"#/components/schemas/contracts.OAuthRequirement"},"type":"array","uniqueItems":false},"runtime_warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false},"timestamp":{"type":"string"},"total_issues":{"type":"integer"},"upstream_errors":{"items":{"$ref":"#/components/schemas/contracts.UpstreamError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DockerStatus":{"properties":{"available":{"type":"boolean"},"error":{"type":"string"},"version":{"type":"string"}},"type":"object"},"contracts.EditRegistrySourceRequest":{"properties":{"name":{"description":"new display name","type":"string"},"servers_url":{"description":"explicit servers-collection URL","type":"string"},"url":{"description":"new base/servers https URL","type":"string"}},"type":"object"},"contracts.ErrorResponse":{"properties":{"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.FindingCounts":{"properties":{"dangerous":{"description":"Tool poisoning, active prompt injection","type":"integer"},"info":{"description":"Low-severity CVEs, informational","type":"integer"},"total":{"type":"integer"},"warning":{"description":"Rug pull, supply chain CVEs with exploits","type":"integer"}},"type":"object"},"contracts.GetConfigResponse":{"properties":{"config":{"description":"The configuration object","type":"object"},"config_path":{"description":"Path to config file","type":"string"}},"type":"object"},"contracts.GetRegistriesResponse":{"properties":{"registries":{"items":{"$ref":"#/components/schemas/contracts.Registry"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerLogsResponse":{"properties":{"count":{"type":"integer"},"logs":{"items":{"$ref":"#/components/schemas/contracts.LogEntry"},"type":"array","uniqueItems":false},"server_name":{"type":"string"}},"type":"object"},"contracts.GetServerToolCallsResponse":{"properties":{"server_name":{"type":"string"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerToolsResponse":{"properties":{"count":{"type":"integer"},"incarnation":{"description":"Incarnation is the committed incarnation of the server the tools were\nread for (Spec 116 FR-017), captured before they were fetched; omitted\nwhen unknown.","type":"integer"},"server_name":{"type":"string"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GetServersResponse":{"properties":{"servers":{"items":{"$ref":"#/components/schemas/contracts.Server"},"type":"array","uniqueItems":false},"stats":{"$ref":"#/components/schemas/contracts.ServerStats"}},"type":"object"},"contracts.GetSessionDetailResponse":{"properties":{"session":{"$ref":"#/components/schemas/contracts.MCPSession"}},"type":"object"},"contracts.GetSessionsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"sessions":{"items":{"$ref":"#/components/schemas/contracts.MCPSession"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetToolCallDetailResponse":{"properties":{"tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"}},"type":"object"},"contracts.GetToolCallsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GlobalToolsResponse":{"properties":{"counts":{"$ref":"#/components/schemas/contracts.ViewAsCounts"},"failed_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"partial":{"type":"boolean"},"stats":{"$ref":"#/components/schemas/contracts.GlobalToolsStats"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GlobalToolsStats":{"properties":{"disabled":{"type":"integer"},"enabled":{"type":"integer"},"pending_approval":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.HealthStatus":{"description":"Unified health status calculated by the backend","properties":{"action":{"description":"Action is the suggested fix action: \"login\", \"restart\", \"enable\", \"approve\", \"view_logs\", \"set_secret\", \"configure\", \"edit_url\", or \"\" (none)\nInvariant: Action always equals Actions[0], or \"\" when Actions is empty.","type":"string"},"actions":{"description":"Actions lists every applicable next step in priority order (FR-012):\nlogin \u003e set_secret \u003e configure \u003e edit_url \u003e approve \u003e restart \u003e\nview_logs \u003e enable. Always non-nil (empty slice, never null).","items":{"type":"string"},"type":"array","uniqueItems":false},"admin_state":{"description":"AdminState indicates the admin state: \"enabled\", \"disabled\", or \"quarantined\"","type":"string"},"detail":{"description":"Detail is an optional longer explanation of the status","type":"string"},"level":{"description":"Level indicates the health level: \"healthy\", \"degraded\", or \"unhealthy\"","type":"string"},"status":{"description":"Status is the ONE status vocabulary rendered as text on every surface\n(Web UI, macOS, tray, CLI) — Spec 109 FR-010/FR-011. Values: \"ready\",\n\"connecting\", \"sign_in_required\", \"needs_review\", \"needs_secret\",\n\"needs_config\", \"error\", \"disabled\". Unlike Level (a severity signal for\nbadge/tray coloring only), no renderer may print Level as text.","type":"string"},"summary":{"description":"Summary is a human-readable status message (e.g., \"Connected (5 tools)\")","type":"string"},"usable":{"description":"Usable reports whether the server can currently serve tool calls. True\nonly when Status == \"ready\".","type":"boolean"}},"type":"object"},"contracts.InfoEndpoints":{"description":"Available API endpoints","properties":{"http":{"description":"HTTP endpoint address (e.g., \"127.0.0.1:8080\")","type":"string"},"socket":{"description":"Unix socket path (empty if disabled)","type":"string"}},"type":"object"},"contracts.InfoResponse":{"properties":{"endpoints":{"$ref":"#/components/schemas/contracts.InfoEndpoints"},"launched_by":{"description":"LaunchedBy is the durable launch provenance of the running core (Spec\n092 FR-001a): \"tray\" when a tray spawned it, \"installer\" when the macOS\nPKG postinstall did, \"\" when user-launched or unknown. Always present\n(possibly empty) so a tray can distinguish \"old core, not mine\" from\n\"old core I may supersede\".","type":"string"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"pid":{"description":"PID is the operating-system process id of the running core (Spec 092\nFR-002). A tray that merely ATTACHED to a core holds no Process handle\nfor it, so without this there is no mechanism at all to stop a stale\ncore — the consent action would have nothing to act on and could only\nprint instructions. Paired with LaunchedBy it is what lets a newer tray\nsupersede a core an older tray started.","type":"integer"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"update_policy":{"$ref":"#/components/schemas/contracts.UpdatePolicy"},"version":{"description":"Current MCPProxy version","type":"string"},"web_ui_url":{"description":"URL to access the web control panel","type":"string"}},"type":"object"},"contracts.IsolationConfig":{"properties":{"cpu_limit":{"type":"string"},"enabled":{"description":"Enabled is the EFFECTIVE isolation state for this server: whether its\nprocess is actually CONFINED, after the global setting, the per-server\noverride, the structural gates and the host's capabilities. It is NOT the\nraw per-server override — read EnabledOverride for that (GH #1142).\n\nREAD-ONLY. The write surfaces reject an ` + "`" + `enabled` + "`" + ` key precisely because\nit is derived: echoing it back would convert \"inherits the global\nsetting\" into a permanent explicit override. Write EnabledOverride.\n\nIt stays a non-pointer bool that is always present on the wire: the macOS\ntray decodes it as a non-optional Swift Bool, so omitting or nulling the\nkey would fail Codable for the whole server payload. Older clients that\nread this field now simply get a true answer.","type":"boolean"},"enabled_override":{"description":"EnabledOverride is the RAW per-server ` + "`" + `isolation.enabled` + "`" + ` override, as\npersisted. Absent means \"inherit the global setting\" — which is a\ndistinct state from an explicit false, and the distinction the reporting\nbug used to destroy.","type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"memory_limit":{"type":"string"},"mode_override":{"description":"ModeOverride is the RAW per-server ` + "`" + `isolation.mode` + "`" + ` override\n(\"docker\" | \"sandbox\" | \"none\"). Absent means \"inherit\".","type":"string"},"network_mode":{"type":"string"},"timeout":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.IsolationDefaults":{"description":"IsolationDefaults exposes the resolved baseline values that\nwould apply when no per-server override is set. Populated on\nlist/get responses; never consumed on PATCH requests.","properties":{"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"runtime_type":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.IsolationEffective":{"description":"IsolationEffective exposes the resolved isolation state (and the rule\nthat decided it) so clients can distinguish \"inherits global\" from an\nexplicit per-server choice. Read-only; never consumed on PATCH.","properties":{"global_mode":{"description":"GlobalMode is what \"inherit\" resolves to right now.","type":"string"},"inherited":{"description":"Inherited is true when the server sets neither ` + "`" + `isolation.enabled` + "`" + ` nor\n` + "`" + `isolation.mode` + "`" + `, so its state tracks the global setting.","type":"boolean"},"isolated":{"description":"Isolated reports whether the process is actually CONFINED. It is NOT\nsimply Mode != \"none\": \"sandbox\" on a host that cannot enforce Landlock\n(any non-Linux OS, or a kernel without the LSM) runs the server\nunconfined, and Source then says \"sandbox-unavailable\" (GH #1142).","type":"boolean"},"mode":{"description":"Mode is the effective isolation mode: \"docker\" | \"sandbox\" | \"none\" —\nexactly what the spawn path branches on.","type":"string"},"source":{"description":"Source names the deciding rule: \"global\", \"server-mode\",\n\"server-opt-out\", \"server-opt-in-ignored\", \"not-stdio\",\n\"already-docker\", \"sandbox-unavailable\" or \"unsupported-mode\".\nTreat an unrecognized value as \"global\".","type":"string"}},"type":"object"},"contracts.LogEntry":{"properties":{"fields":{"type":"object"},"level":{"type":"string"},"message":{"type":"string"},"server":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.MCPSession":{"properties":{"client_id":{"description":"Scope attribution (Spec 108 FR-033). ClientID and TokenName are the\ncredential the session initialized with; Profile and ProfileSource are\nthe session's latest effective resolution. Empty on legacy sessions.","type":"string"},"client_name":{"type":"string"},"client_version":{"type":"string"},"end_time":{"type":"string"},"experimental":{"items":{"type":"string"},"type":"array","uniqueItems":false},"has_roots":{"description":"MCP Client Capabilities","type":"boolean"},"has_sampling":{"type":"boolean"},"id":{"type":"string"},"last_activity":{"type":"string"},"profile":{"type":"string"},"profile_source":{"type":"string"},"start_time":{"type":"string"},"status":{"type":"string"},"token_name":{"type":"string"},"tool_call_count":{"type":"integer"},"total_tokens":{"type":"integer"},"work_session_id":{"type":"string"},"workspace_name":{"description":"Workspace / work session (Spec 082). WorkspaceName is the project's\nbasename — the full local path is never exposed. WorkSessionID groups the\nreconnects that make up one stretch of user work.","type":"string"}},"type":"object"},"contracts.MetadataStatus":{"properties":{"authorization_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"error":{"type":"string"},"found":{"type":"boolean"},"url_checked":{"type":"string"}},"type":"object"},"contracts.MissingSecretInfo":{"properties":{"secret_name":{"type":"string"},"used_by":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.NPMPackageInfo":{"properties":{"exists":{"type":"boolean"},"install_cmd":{"type":"string"}},"type":"object"},"contracts.OAuthConfig":{"properties":{"auth_server_metadata_url":{"type":"string"},"auth_url":{"type":"string"},"authorization_endpoint":{"description":"Spec 113-b operator overrides of OAuth discovery (https-or-loopback URLs,\nnever credentials). Empty means \"use discovery\".","type":"string"},"client_id":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_port":{"type":"integer"},"registration_endpoint":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"token_endpoint":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires","type":"string"},"token_url":{"type":"string"},"token_valid":{"description":"Whether token is currently valid","type":"boolean"}},"type":"object"},"contracts.OAuthErrorDetails":{"description":"Structured discovery/failure details","properties":{"authorization_server_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"dcr_status":{"$ref":"#/components/schemas/contracts.DCRStatus"},"protected_resource_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"server_url":{"type":"string"}},"type":"object"},"contracts.OAuthFlowError":{"properties":{"correlation_id":{"description":"Flow tracking ID for log correlation","type":"string"},"debug_hint":{"description":"CLI command for log lookup","type":"string"},"details":{"$ref":"#/components/schemas/contracts.OAuthErrorDetails"},"error_code":{"description":"Machine-readable error code (e.g., OAUTH_NO_METADATA)","type":"string"},"error_type":{"description":"Category of OAuth runtime failure","type":"string"},"message":{"description":"Human-readable error description","type":"string"},"request_id":{"description":"HTTP request ID (from PR #237)","type":"string"},"server_name":{"description":"Server that failed OAuth","type":"string"},"success":{"description":"Always false","type":"boolean"},"suggestion":{"description":"Actionable remediation hint","type":"string"}},"type":"object"},"contracts.OAuthIssue":{"properties":{"documentation_url":{"type":"string"},"error":{"type":"string"},"issue":{"type":"string"},"missing_params":{"items":{"type":"string"},"type":"array","uniqueItems":false},"resolution":{"type":"string"},"server_name":{"type":"string"}},"type":"object"},"contracts.OAuthRequirement":{"properties":{"expires_at":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"state":{"type":"string"}},"type":"object"},"contracts.OAuthStartResponse":{"properties":{"auth_url":{"description":"Authorization URL (always included for manual use)","type":"string"},"browser_error":{"description":"Error message if browser launch failed","type":"string"},"browser_opened":{"description":"Whether browser launch succeeded","type":"boolean"},"correlation_id":{"description":"UUID for tracking this flow","type":"string"},"message":{"description":"Human-readable status message","type":"string"},"server_name":{"description":"Name of the server being authenticated","type":"string"},"success":{"description":"Always true for successful start","type":"boolean"}},"type":"object"},"contracts.PreflightPolicy":{"properties":{"exclude_destructive":{"type":"boolean"},"exclude_open_world":{"type":"boolean"},"read_only_only":{"type":"boolean"}},"type":"object"},"contracts.PreflightReason":{"type":"string","x-enum-varnames":["PreflightReasonServerInitializing","PreflightReasonServerUnhealthy","PreflightReasonServerDisabled","PreflightReasonServerQuarantined","PreflightReasonToolPendingApproval","PreflightReasonToolChanged","PreflightReasonToolBlockedByUser","PreflightReasonOAuthRequired","PreflightReasonHashMismatch","PreflightReasonServerNotInScope","PreflightReasonToolDeniedByConfig","PreflightReasonMissingAnnotation","PreflightReasonPolicyFiltered","PreflightReasonNotFound","PreflightReasonServerNotConfigured","PreflightReasonToolBlockedByProfile","PreflightReasonTokenPermissionDenied"]},"contracts.PreflightRequest":{"properties":{"policy":{"$ref":"#/components/schemas/contracts.PreflightPolicy"},"profile":{"description":"Profile evaluates under a named profile's server scope and tool policy;\nit can only narrow a credential's own pin. Unknown: 400.","type":"string"},"tools":{"description":"Tools is 1..100 entries BEFORE dedup; duplicates are collapsed, and\nduplicate ids carrying different pins are a validation error.","items":{"$ref":"#/components/schemas/contracts.PreflightToolRef"},"type":"array","uniqueItems":false},"wait_ms":{"description":"WaitMS polls local state for up to this many milliseconds (cap 10000)\nwhile every failure is retryable-class.","type":"integer"}},"type":"object"},"contracts.PreflightResponse":{"properties":{"checked_at":{"type":"string"},"tools":{"description":"Tools are ordered by first occurrence of each unique id in the request.","items":{"$ref":"#/components/schemas/contracts.PreflightToolResult"},"type":"array","uniqueItems":false},"verdict":{"$ref":"#/components/schemas/contracts.PreflightVerdict"},"waited_ms":{"description":"WaitedMS is present when wait_ms was requested (0 when the wait\nsemaphore was exhausted and the request resolved immediately).","type":"integer"}},"type":"object"},"contracts.PreflightStatus":{"type":"string","x-enum-varnames":["PreflightStatusReady","PreflightStatusUnavailable"]},"contracts.PreflightToolRef":{"properties":{"id":{"description":"ID is a canonical \"\u003cserver\u003e:\u003ctool\u003e\" id. A malformed id is answered with a\nper-ID not_found carrying a format hint, never a request-level error.","type":"string"},"pin_hash":{"description":"PinHash is \"sha256/v{N}:{hex}\" — the schema version is embedded so a\nproxy-side hash-algorithm bump is distinguishable from upstream drift.","type":"string"}},"type":"object"},"contracts.PreflightToolResult":{"properties":{"action":{"type":"string"},"detail":{"type":"string"},"did_you_mean":{"description":"DidYouMean carries up to 3 nearest caller-visible ids on not_found. It\nnever crosses a scope boundary and never names a quarantined server's\ntools.","items":{"type":"string"},"type":"array","uniqueItems":false},"hash":{"description":"Hash is the tool's current pin (\"sha256/v{N}:{hex}\") — operator tier,\nready results only. Never disclosed to an agent token.","type":"string"},"id":{"type":"string"},"reason":{"$ref":"#/components/schemas/contracts.PreflightReason"},"remediation":{"type":"string"},"retryable":{"type":"boolean"},"status":{"$ref":"#/components/schemas/contracts.PreflightStatus"}},"type":"object"},"contracts.PreflightVerdict":{"type":"string","x-enum-varnames":["PreflightVerdictReady","PreflightVerdictDegradedRetryable","PreflightVerdictBlocked","PreflightVerdictUnknownIDs"]},"contracts.QuarantineStats":{"description":"Tool quarantine metrics for this server","properties":{"blocked_count":{"description":"Number of disabled (blocked) tools","type":"integer"},"changed_count":{"description":"Number of tools whose description/schema changed since approval","type":"integer"},"pending_count":{"description":"Number of newly discovered tools awaiting approval","type":"integer"}},"type":"object"},"contracts.RefreshRegistryResponse":{"properties":{"cleared":{"description":"number of cached entries dropped","type":"integer"},"registry_id":{"type":"string"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag (MCP-866): \"official/trusted\" for built-in\ndefaults, \"custom/unverified\" for user-added registries.","type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"trusted":{"description":"Trusted indicates whether this is an official, shipped-by-default\nregistry. Trust is derived from membership in the default set, never\nfrom self-assertion in config.","type":"boolean"},"url":{"type":"string"}},"type":"object"},"contracts.RegistryCacheInfo":{"properties":{"age_seconds":{"type":"number"},"stale":{"type":"boolean"}},"type":"object"},"contracts.RegistryUnavailable":{"properties":{"reason":{"type":"string"}},"type":"object"},"contracts.ReplayToolCallRequest":{"properties":{"arguments":{"description":"Modified arguments for replay","type":"object"}},"type":"object"},"contracts.ReplayToolCallResponse":{"properties":{"error":{"description":"Error if replay failed","type":"string"},"new_call_id":{"description":"ID of the newly created call","type":"string"},"new_tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"replayed_from":{"description":"Original call ID","type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.RepositoryInfo":{"description":"Detected package info","properties":{"npm":{"$ref":"#/components/schemas/contracts.NPMPackageInfo"}},"type":"object"},"contracts.RepositoryServer":{"properties":{"connect_url":{"description":"Alternative connection URL","type":"string"},"created_at":{"type":"string"},"description":{"type":"string"},"id":{"type":"string"},"install_cmd":{"description":"Installation command","type":"string"},"name":{"type":"string"},"registry":{"description":"Which registry this came from","type":"string"},"repository_info":{"$ref":"#/components/schemas/contracts.RepositoryInfo"},"source_code_url":{"description":"Source repository URL","type":"string"},"updated_at":{"type":"string"},"url":{"description":"MCP endpoint for remote servers only","type":"string"}},"type":"object"},"contracts.SearchRegistryServersResponse":{"properties":{"cache":{"$ref":"#/components/schemas/contracts.RegistryCacheInfo"},"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"},"unavailable":{"$ref":"#/components/schemas/contracts.RegistryUnavailable"}},"type":"object"},"contracts.SearchResult":{"properties":{"matches":{"type":"integer"},"score":{"type":"number"},"snippet":{"type":"string"},"tool":{"$ref":"#/components/schemas/contracts.Tool"}},"type":"object"},"contracts.SearchToolsResponse":{"properties":{"query":{"type":"string"},"results":{"items":{"$ref":"#/components/schemas/contracts.SearchResult"},"type":"array","uniqueItems":false},"took":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.SecurityScanSummary":{"description":"Latest security scan results summary","properties":{"deep_scan":{"$ref":"#/components/schemas/contracts.DeepScanDescriptor"},"finding_counts":{"$ref":"#/components/schemas/contracts.FindingCounts"},"last_scan_at":{"type":"string"},"risk_score":{"description":"0-100","type":"integer"},"scanners_failed":{"type":"integer"},"scanners_run":{"description":"Scanner coverage for the primary (baseline) scan pass — informational only.\nSpec 077 US3 (FR-008/FR-014): Status is derived SOLELY from the\ndeterministic baseline findings; a failed Docker deep scanner no longer\ndowngrades a clean verdict. That failure is surfaced via DeepScan instead.","type":"integer"},"scanners_total":{"type":"integer"},"status":{"description":"\"clean\", \"warnings\", \"dangerous\", \"failed\", \"not_scanned\", \"scanning\"","type":"string"}},"type":"object"},"contracts.Server":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"authenticated":{"description":"OAuth authentication status","type":"boolean"},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges mirrors config.ServerConfig.AutoApproveToolChanges\n(MCP-2930): the per-server intent to auto-approve new/changed tools past\nthe trust baseline. Tri-state *bool — nil means \"never set\" (omitted from\nthe payload), so the Web UI toggle (MCP-2932) can distinguish unset from\nan explicit false. Read-only on the GET path; PATCH/POST accept it via\nAddServerRequest.","type":"boolean"},"command":{"type":"string"},"connected":{"type":"boolean"},"connected_at":{"type":"string"},"connecting":{"type":"boolean"},"created":{"type":"string"},"diagnostic":{"$ref":"#/components/schemas/contracts.Diagnostic"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"error_code":{"type":"string"},"expose_prompts":{"description":"ExposePrompts mirrors config.ServerConfig.ExposePrompts (F9): the per-server\nprompt-aggregation override. Tri-state *bool — nil/omitted means \"inherit\ndefault aggregation\". Surfaced on GET so a caller that PATCHed the override\ncan read it back; PATCH/POST accept it via AddServerRequest.","type":"boolean"},"forward_headers":{"description":"ForwardHeaders mirrors config.ServerConfig.ForwardHeaders (Spec 112): the\nallowlist of inbound MCP client header NAMES forwarded to this server on\ntools/call. Names only, never values. Omitted when empty.","items":{"type":"string"},"type":"array","uniqueItems":false},"generation":{"description":"Generation is the server's committed config generation (Spec 116\nFR-017): it changes whenever the server's configuration changes.","type":"integer"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"health":{"$ref":"#/components/schemas/contracts.HealthStatus"},"id":{"type":"string"},"incarnation":{"description":"Incarnation changes only when the name is removed and created again\n(Spec 116 FR-017). Approval endpoints accept it back as\nexpected_incarnation and answer 409 server_replaced on a mismatch.","type":"integer"},"init_timeout":{"description":"InitTimeout mirrors config.ServerConfig.InitTimeout (MCP-3322 / GH #760):\nthe per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override. Serialized as\na duration string (e.g. \"120s\"); nil/omitted means \"inherit the global\ndefault\". Surfaced on the GET path so clients can read back a configured\noverride; PATCH/POST accept it via AddServerRequest.","type":"string"},"isolation":{"$ref":"#/components/schemas/contracts.IsolationConfig"},"isolation_defaults":{"$ref":"#/components/schemas/contracts.IsolationDefaults"},"isolation_effective":{"$ref":"#/components/schemas/contracts.IsolationEffective"},"last_error":{"type":"string"},"last_reconnect_at":{"type":"string"},"last_retry_time":{"type":"string"},"max_concurrent_requests":{"description":"Spec 093 (GH #955) — per-server concurrency overrides, scope (c) of\nFR-020. Each setting is tri-state: nil (omitted) means \"inherit\nserver_concurrency_defaults\", 0 disables that setting for this server,\npositive overrides it. Surfaced on the GET path so a caller can read back\nwhat it set; PATCH/POST accept them via AddServerRequest. The effective\nconcurrency for a server is additionally bounded by the global aggregate\nlimiter, which is NOT an inheritance source for these fields.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_count":{"type":"integer"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets this disconnected server","type":"boolean"},"retry_count":{"type":"integer"},"retry_stopped":{"description":"RetryStopped reports that automatic reconnection has been given up for\ngood because the failure is deterministic and unrecoverable — a missing\nbinary, an image without the interpreter, an unparseable config (GH\n#1145). It is NOT ordinary exponential backoff, which keeps retrying;\nnothing will happen until the user fixes the config or restarts the\nserver. RetryStoppedCode is the stable MCPX_* code that proved it and\nRetryStoppedReason the catalog message explaining how to fix it. All three\nare omitted for servers that are healthy or still retrying.","type":"boolean"},"retry_stopped_code":{"type":"string"},"retry_stopped_reason":{"type":"string"},"security_scan":{"$ref":"#/components/schemas/contracts.SecurityScanSummary"},"should_retry":{"type":"boolean"},"source_registry_id":{"description":"MCP-901 — registry provenance of an upstream that was added from a\nregistry. SourceRegistryID names the source registry (empty for\nmanually-configured servers); SourceRegistryProvenance is the trust tag\nrecorded at add time (\"official/trusted\" or \"custom/unverified\"). Both\nare projected from config.ServerConfig so the approval/quarantine view\ncan render an \"added from \u003cregistry\u003e · unverified\" origin badge. Optional\nand omitted when empty — clients that pre-date this treat them as absent.","type":"string"},"source_registry_provenance":{"type":"string"},"status":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires (ISO 8601)","type":"string"},"tool_count":{"type":"integer"},"tool_list_token_size":{"description":"Token size for this server's tools","type":"integer"},"trust_mode":{"description":"TrustMode mirrors config.ServerConfig.TrustMode (spec 086): the per-server\ntrust tier (\"auto\"/\"scan\"/\"manual\"). Surfaced on the GET path so clients can\nread back the persisted mode; PATCH/POST accept it via AddServerRequest.\nOmitted when empty (server predates the field / relies on legacy flags).","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"user_logged_out":{"description":"True if user explicitly logged out (prevents auto-reconnection)","type":"boolean"},"working_dir":{"type":"string"}},"type":"object"},"contracts.ServerActionResponse":{"properties":{"action":{"type":"string"},"async":{"type":"boolean"},"server":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ServerStats":{"properties":{"connected_servers":{"type":"integer"},"docker_containers":{"type":"integer"},"quarantined_servers":{"type":"integer"},"token_metrics":{"$ref":"#/components/schemas/contracts.ServerTokenMetrics"},"total_servers":{"type":"integer"},"total_tools":{"type":"integer"}},"type":"object"},"contracts.ServerTokenMetrics":{"properties":{"average_query_result_size":{"description":"Typical retrieve_tools output (tokens)","type":"integer"},"estimated":{"description":"Estimated (Spec 109-k FR-070-ish, url-filter-contract.md / audit F-Token):\ntrue while AverageQueryResultSize is a synthetic simulation (a sample of\nthe first ` + "`" + `tools_limit` + "`" + ` tools' schemas — no real retrieve_tools call has\ncompleted yet in this runtime's usage aggregate); false once at least one\nreal retrieve_tools call has, at which point AverageQueryResultSize is\nderived from the real observed average response size instead. The Web\nUI and macOS render an \"estimate\" label while this is true.","type":"boolean"},"per_server_tool_list_sizes":{"additionalProperties":{"type":"integer"},"description":"Token size per server","type":"object"},"saved_tokens":{"description":"Difference","type":"integer"},"saved_tokens_percentage":{"description":"Percentage saved","type":"number"},"total_server_tool_list_size":{"description":"All upstream tools combined (tokens)","type":"integer"}},"type":"object"},"contracts.SuccessResponse":{"properties":{"data":{"type":"object"},"success":{"type":"boolean"}},"type":"object"},"contracts.Tier":{"description":"Tier is computed by AnnotationTier (Spec 109 FR-028/X11) from\nAnnotations — read|write|destructive|unannotated. Set by every producer\nof a Tool (enrichServerTools, the global tools handler); never left for\na consuming surface to compute.","type":"string","x-enum-varnames":["TierRead","TierWrite","TierDestructive","TierUnannotated","TierUnknown"]},"contracts.TokenMetrics":{"description":"Token usage metrics (nil for older records)","properties":{"encoding":{"description":"Encoding used (e.g., cl100k_base)","type":"string"},"estimated_cost":{"description":"Optional cost estimate","type":"number"},"input_tokens":{"description":"Tokens in the request","type":"integer"},"model":{"description":"Model used for tokenization","type":"string"},"output_tokens":{"description":"Tokens in the response","type":"integer"},"total_tokens":{"description":"Total tokens (input + output)","type":"integer"},"truncated_tokens":{"description":"Tokens removed by truncation","type":"integer"},"was_truncated":{"description":"Whether response was truncated","type":"boolean"}},"type":"object"},"contracts.Tool":{"properties":{"access":{"$ref":"#/components/schemas/contracts.ToolAccess"},"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"approval_status":{"type":"string"},"config_denied":{"description":"ConfigDenied is true when the tool is denied by the server's static\nenabled_tools / disabled_tools config. The user cannot override this toggle.","type":"boolean"},"description":{"type":"string"},"disabled":{"description":"Disabled mirrors ToolApprovalRecord.Disabled so per-tool enable state is\navailable without a second round-trip to the approvals endpoint. Absent\nin the JSON when false (default) to keep responses compact.","type":"boolean"},"hash":{"description":"Hash is the tool's current stored hash rendered in the preflight pin\nformat \"sha256/v{N}:{hex}\" (Spec 098 FR-011), where N is the approval\nrecord's HashSchemaVersion. It is the authoring surface for\n` + "`" + `POST /api/v1/preflight` + "`" + ` pins and ` + "`" + `mcpproxy tools preflight --pin` + "`" + `:\ncopy the value straight into a pin.\n\nDisclosure is OPERATOR TIER ONLY — same rule as the preflight per-tool\nresult. The field is omitted for agent-token callers and for tools with\nno stored hash (no approval record yet, or a record written before\nhashes existed).","type":"string"},"held_reason":{"description":"HeldReason, HeldVerdict and HeldSignals mirror the same-named fields on\nstorage.ToolApprovalRecord: the offline-scan evidence that made\ntrust_mode: scan hold this tool for review (spec 086 FR-018). HeldSignals\nnames the matched deterministic check ids, e.g.\n\"tpa.TPA-2026-0001.hidden_instruction\", so a reviewer can see WHY the tool\nis held. All three are omitted for tools that are not held by the scan gate\n(including every record written before the field existed).","type":"string"},"held_signals":{"items":{"type":"string"},"type":"array","uniqueItems":false},"held_verdict":{"type":"string"},"last_used":{"type":"string"},"name":{"type":"string"},"profile_tier":{"description":"ProfileTier and Access are present only in a view-as listing (a tools\nlisting with ?client= or ?profile=, Spec 108 FR-032). ProfileTier is the\ntool's tier under the viewed subject's profile (Tier above stays the\nintrinsic tier); Access is the subject's verdict for the tool. Absent\notherwise, so an ordinary listing is byte-identical to before.","type":"string","x-enum-varnames":["TierRead","TierWrite","TierDestructive","TierUnannotated","TierUnknown"]},"schema":{"type":"object"},"server_incarnation":{"description":"ServerIncarnation is the committed incarnation of the server this row\nwas read for (Spec 116 FR-017), captured before the tools were fetched.\nA client echoes it as expected_incarnation on a per-tool decision, so a\nstale row is refused (409 server_replaced) after a same-name re-add.\nOmitted when the server has no committed incarnation.","type":"integer"},"server_name":{"type":"string"},"tier":{"$ref":"#/components/schemas/contracts.Tier"},"usage":{"type":"integer"}},"type":"object"},"contracts.ToolAccess":{"properties":{"callable":{"type":"boolean"},"reason":{"type":"string"},"visible":{"type":"boolean"}},"type":"object"},"contracts.ToolAnnotation":{"description":"Tool behavior hints snapshot","properties":{"destructiveHint":{"type":"boolean"},"idempotentHint":{"type":"boolean"},"openWorldHint":{"type":"boolean"},"readOnlyHint":{"type":"boolean"},"title":{"type":"string"}},"type":"object"},"contracts.ToolCallRecord":{"description":"The new tool call record","properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"arguments":{"description":"Tool arguments","type":"object"},"arguments_truncated":{"description":"ArgumentsTruncated marks Arguments as a placeholder rather than the\narguments the tool was called with. Replaying such a record without\nsupplying arguments explicitly is refused.","type":"boolean"},"config_path":{"description":"Active config file path","type":"string"},"duration":{"description":"Duration in nanoseconds","type":"integer"},"error":{"description":"Error message (failure only)","type":"string"},"execution_type":{"description":"\"direct\" or \"code_execution\"","type":"string"},"id":{"description":"Unique identifier","type":"string"},"mcp_client_name":{"description":"MCP client name from InitializeRequest","type":"string"},"mcp_client_version":{"description":"MCP client version","type":"string"},"mcp_session_id":{"description":"MCP session identifier","type":"string"},"metrics":{"$ref":"#/components/schemas/contracts.TokenMetrics"},"parent_call_id":{"description":"Links nested calls to parent code_execution","type":"string"},"request_id":{"description":"Request correlation ID","type":"string"},"response":{"description":"Tool response (success only)","type":"object"},"response_bytes":{"description":"Marshalled response size before truncation","type":"integer"},"response_truncated":{"description":"ResponseTruncated and ResponseBytes describe a STORAGE-side cut (#1176):\nthe caller received the response whole, and only the persisted copy was\nshortened to tool_call_max_response_size. When ResponseTruncated is true\nthe Response object carries {truncated, original_bytes, preview, note}\ninstead of the upstream result, and ResponseBytes is its size before the\ncut.","type":"boolean"},"server_id":{"description":"Server identity hash","type":"string"},"server_name":{"description":"Human-readable server name","type":"string"},"timestamp":{"description":"When the call was made","type":"string"},"tool_name":{"description":"Tool name (without server prefix)","type":"string"}},"type":"object"},"contracts.UpdateInfo":{"description":"Update information (if available)","properties":{"available":{"description":"Whether an update is available","type":"boolean"},"behind_summary":{"description":"Spec 079 FR-002 — how far behind the running build is. All four are\nadditive (FR-021) and absent when the delta could not be resolved, in\nwhich case every surface renders its pre-delta wording.","type":"string"},"check_error":{"description":"Error message if update check failed","type":"string"},"checked_at":{"description":"When the update check was performed","type":"string"},"install_channel":{"description":"Detected install channel (homebrew, dmg, deb, rpm, docker, go-install, windows-installer, tarball, unknown) — Spec 079 FR-008","type":"string"},"is_prerelease":{"description":"Whether the latest version is a prerelease","type":"boolean"},"latest_version":{"description":"Latest version available (e.g., \"v1.2.3\")","type":"string"},"nudges_suppressed":{"description":"UI surfaces must stay quiet (CI / non-interactive context); machine-readable fields still report the facts — Spec 079 FR-019","type":"boolean"},"release_url":{"description":"URL to the release page","type":"string"},"releases_behind":{"description":"Releases on the offered channel between the running and offered versions","type":"integer"},"releases_behind_saturated":{"description":"ReleasesBehind is a lower bound: the running build predates the scanned release window","type":"boolean"},"update_command":{"description":"One-line update command for the channel; only set when an update is available and the channel has one — Spec 079 FR-009","type":"string"},"weeks_behind":{"description":"Whole weeks between the two releases' publish dates; 0 is a real value, absent means unknown","type":"integer"}},"type":"object"},"contracts.UpdatePolicy":{"description":"UpdatePolicy is the effective, hot-reloadable update policy (Spec 092\nFR-015). Always present: the ` + "`" + `update` + "`" + ` object above is omitted both when\nupdate checking is disabled AND when no check has produced a result\nyet, so its absence cannot tell a client whether it is allowed to run\nits own (e.g. Sparkle feed) check. This field states the answer.","properties":{"channel":{"description":"Channel is the tracked release channel: \"stable\" or \"rc\".","type":"string"},"enabled":{"description":"Enabled is the effective automatic-check kill switch: update_check.enabled\nwith MCPPROXY_DISABLE_AUTO_UPDATE=true winning over it. A user-initiated\n\"Check for Updates\" stays available regardless.","type":"boolean"},"nudges_suppressed":{"description":"NudgesSuppressed asks UI surfaces to stay quiet (CI / non-interactive)\nwhile machine-readable fields keep reporting the facts.","type":"boolean"}},"type":"object"},"contracts.UpstreamError":{"properties":{"error_message":{"type":"string"},"server_name":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.UsageAggregateResponse":{"properties":{"freshness_ms":{"description":"age of the underlying snapshot in ms","type":"integer"},"generated_at":{"type":"string"},"other":{"$ref":"#/components/schemas/contracts.UsageOtherBucket"},"timeline":{"items":{"$ref":"#/components/schemas/contracts.UsageTimeBucket"},"type":"array","uniqueItems":false},"token_source":{"description":"\"bytes\" (size-based proxy, FR-006)","type":"string"},"tokens_saved":{"description":"echoed from ServerTokenMetrics (FR-007)","type":"integer"},"tokens_saved_estimated":{"description":"TokensSavedEstimated echoes ServerTokenMetrics.Estimated (Spec 109-k):\ntrue while TokensSaved is a synthetic simulation rather than derived\nfrom a real retrieve_tools call. Dropped (false, the zero value) for a\nscoped caller along with TokensSaved itself, above.","type":"boolean"},"tokens_saved_percentage":{"type":"number"},"tools":{"items":{"$ref":"#/components/schemas/contracts.UsageToolStat"},"type":"array","uniqueItems":false},"total_calls":{"description":"TotalCalls and TotalErrors are the headline counts for the window: the sum\nof the timeline this same response carries, so the tiles and the histogram\nunder them cannot disagree. They are NOT the sum of Tools — that list is\nlifetime-cumulative, upstream-only and truncated to top-N, and summing it\nclient-side is what made the Usage tab print a third number for the same\n24 hours (audit finding F1, #1046). The population is\nstorage.CountsAsCall, shared with ActivitySummaryResponse.CallCount.\n\nTwo bounds on how exactly this matches the Activity Log's own count.\nBoth are bounded and disclosed, unlike the population mismatch they\nreplace, which was unbounded and silent:\n\n - Window granularity is the timeline's: whole hour buckets, so the span\n is the requested window rounded up to a bucket edge.\n - This response is served from a snapshot behind a short read cache\n (observability.usage_cache_ttl, 5s by default) so the endpoint never\n scans the activity log per request, while the summary endpoint counts\n live. Calls that land inside that window appear on the Activity Log\n first. FreshnessMs and GeneratedAt say how old the figures are, and\n the Usage tab prints it (\"Updated 3s ago\").","type":"integer"},"total_errors":{"type":"integer"},"window":{"type":"string"}},"type":"object"},"contracts.UsageOtherBucket":{"description":"present only when the list was truncated to top-N","properties":{"calls":{"type":"integer"},"tools_folded":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageTimeBucket":{"properties":{"calls":{"type":"integer"},"errors":{"type":"integer"},"start":{"type":"string"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageToolStat":{"properties":{"avg_req_bytes":{"description":"null when no sized request calls","type":"integer"},"avg_resp_bytes":{"description":"null when sized_calls == 0 (only legacy 0-byte calls)","type":"integer"},"blocked":{"type":"integer"},"calls":{"type":"integer"},"error_rate":{"type":"number"},"errors":{"type":"integer"},"last_used":{"type":"string"},"p50_exceeds":{"type":"boolean"},"p50_ms":{"description":"P50Ms and P95Ms are read off a fixed latency histogram, so they are BUCKET\nBOUNDS, not measured durations: the true percentile is at or below the\nvalue, and a client must render it as a bound (\"≤ 5 ms\"). P50Exceeds /\nP95Exceeds flip that reading for the unbounded overflow bucket, where the\nvalue is the last bound and the truth is above it (\"\u003e 10 s\").","type":"integer"},"p95_exceeds":{"type":"boolean"},"p95_ms":{"type":"integer"},"rejected":{"description":"spec 093: shed by a concurrency limit; never executed, so excluded from calls/latency","type":"integer"},"server":{"type":"string"},"sized_calls":{"description":"calls with known response size (basis for avg_resp_bytes)","type":"integer"},"tool":{"type":"string"},"total_req_bytes":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.ValidateConfigResponse":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false},"valid":{"type":"boolean"}},"type":"object"},"contracts.ValidationError":{"properties":{"field":{"type":"string"},"message":{"type":"string"}},"type":"object"},"contracts.ViewAsCounts":{"description":"Counts is present only for a NON-administrator profile view-as (Spec 108\nFR-032): the response then lists only the visible rows, and this is the\nonly trace of the rest, with no per-server, per-tier or per-reason\nbreakdown.","properties":{"hidden":{"type":"integer"},"visible":{"type":"integer"}},"type":"object"},"data":{"properties":{"data":{"$ref":"#/components/schemas/contracts.InfoResponse"}},"type":"object"},"httpapi.AccessExplanationData":{"properties":{"first_failure":{"type":"string","x-enum-varnames":["StepCredential","StepProfile","StepServerInScope","StepToolRule","StepTierCap","StepTokenPermission","StepGlobalGate","StepServerState","StepToolApproval"]},"fixes":{"items":{"$ref":"#/components/schemas/runtime.Fix"},"type":"array","uniqueItems":false},"profile":{"$ref":"#/components/schemas/runtime.ExplainProfileView"},"steps":{"items":{"$ref":"#/components/schemas/runtime.ExplainStepView"},"type":"array","uniqueItems":false},"subject":{"$ref":"#/components/schemas/runtime.ExplainSubjectView"},"tool":{"type":"string"},"verdict":{"$ref":"#/components/schemas/profile.ExplainVerdict"}},"type":"object"},"httpapi.AddServerRequest":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve\nnew/changed tools past the trust baseline (MCP-2930). Tri-state *bool:\na nil pointer means \"leave unchanged\" on PATCH; a present value\n(including false) is applied. Mirrors config.ServerConfig's *bool\nsemantics — do NOT collapse to a plain bool, or an omitted field would\nsilently reset a previously-set value.","type":"boolean"},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"expose_prompts":{"description":"ExposePrompts is the per-server override for prompt aggregation (F9):\nwhether this server's advertised MCP prompts are merged into mcpproxy's\nprompts/list. Tri-state *bool mirroring config.ServerConfig.ExposePrompts —\na nil pointer means \"leave unchanged\" on PATCH (and \"inherit the default\naggregate behavior\" on create); a present value (including false) is applied.","type":"boolean"},"forward_headers":{"description":"ForwardHeaders is the per-server allowlist of inbound MCP client header\nNAMES forwarded to this server on tools/call (Spec 112). Names only, never\nvalues. On PATCH a nil slice (field omitted) leaves the stored allowlist\nunchanged and an empty array ([]) clears it. Invalid, denied, duplicate\nand static-header-colliding names are rejected with 400.","items":{"type":"string"},"type":"array","uniqueItems":false},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"init_timeout":{"description":"InitTimeout is the per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override\n(MCP-3322 / GH #760), serialized as a duration string (e.g. \"120s\"). A nil\npointer means \"leave unchanged\" on PATCH; a present value is applied.\nMirrors config.ServerConfig.InitTimeout's *Duration tri-state.","type":"string"},"isolation":{"$ref":"#/components/schemas/httpapi.IsolationRequest"},"max_concurrent_requests":{"description":"MaxConcurrentRequests / QueueSize / QueueTimeout are the per-server\nconcurrency overrides (spec 093 / GH #955, FR-020 scope (c)). Each is\ntri-state: a nil pointer means \"leave unchanged\" on PATCH and \"inherit\nserver_concurrency_defaults\" on create; an explicit 0 disables that\nsetting for this server; a positive value overrides it. Do NOT collapse\nthem to plain values — an omitted field would then silently reset a\nconfigured limit.","type":"integer"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"type":"boolean"},"trust_mode":{"description":"TrustMode is the per-server trust tier (spec 086): \"auto\", \"scan\", or\n\"manual\". Empty means \"leave unchanged\" on PATCH (and inherit the migrated\ndefault on create). A non-empty value is applied to ServerConfig.TrustMode\nand resolved by EffectiveTrustMode (an unrecognized value fails closed to\nmanual). This is the REST seam for changing the trust tier via\nPOST/PATCH /api/v1/servers.","type":"string"},"url":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.BindingGuardResponse":{"properties":{"bindings":{"items":{"$ref":"#/components/schemas/httpapi.GuardBinding"},"type":"array","uniqueItems":false},"code":{"description":"binding_bypassable_without_auth","type":"string"},"error":{"description":"Human-readable, byte-stable refusal text","type":"string"},"fixes":{"items":{"$ref":"#/components/schemas/httpapi.GuardFixOption"},"type":"array","uniqueItems":false},"request_id":{"type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.BulkAssignRequest":{"properties":{"from_profile":{"type":"string"},"mode":{"type":"string"},"to_profile":{"type":"string"}},"type":"object"},"httpapi.BulkAssignResponse":{"properties":{"moved":{"items":{"type":"string"},"type":"array","uniqueItems":false},"skipped":{"items":{"$ref":"#/components/schemas/httpapi.BulkAssignSkipped"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.BulkAssignSkipped":{"properties":{"client_id":{"type":"string"},"code":{"type":"string"},"error":{"type":"string"}},"type":"object"},"httpapi.CanonicalConfigPath":{"properties":{"description":{"description":"Brief description","type":"string"},"exists":{"description":"Whether the file exists","type":"boolean"},"format":{"description":"Format identifier (e.g., \"claude_desktop\")","type":"string"},"name":{"description":"Display name (e.g., \"Claude Desktop\")","type":"string"},"os":{"description":"Operating system (darwin, windows, linux)","type":"string"},"path":{"description":"Full path to the config file","type":"string"}},"type":"object"},"httpapi.CanonicalConfigPathsResponse":{"properties":{"os":{"description":"Current operating system","type":"string"},"paths":{"description":"List of canonical config paths","items":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPath"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ClientBindingErrorResponse":{"properties":{"code":{"type":"string"},"error":{"type":"string"},"field":{"type":"string"},"request_id":{"type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.ClientBindingResponse":{"properties":{"client":{"$ref":"#/components/schemas/httpapi.clientPresence"},"warnings":{"items":{"$ref":"#/components/schemas/httpapi.ClientWarning"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ClientSnippet":{"properties":{"generic_http":{"type":"string"},"header_name":{"type":"string"}},"type":"object"},"httpapi.ClientWarning":{"properties":{"action":{"$ref":"#/components/schemas/runtime.WarningAction"},"bindings":{"items":{"$ref":"#/components/schemas/runtime.BindingRef"},"type":"array","uniqueItems":false},"client_id":{"type":"string"},"code":{"$ref":"#/components/schemas/profile.WarningCode"},"fixes":{"items":{"$ref":"#/components/schemas/runtime.GuardFix"},"type":"array","uniqueItems":false},"message":{"type":"string"},"severity":{"$ref":"#/components/schemas/profile.WarningSeverity"}},"type":"object"},"httpapi.ConnectConflictResponse":{"properties":{"action":{"description":"already_exists | precondition_failed","type":"string"},"data":{"$ref":"#/components/schemas/connect.ConnectResult"},"error":{"description":"Human-readable message","type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.ConnectRequest":{"properties":{"force":{"description":"Overwrite existing entry","type":"boolean"},"keyless":{"type":"boolean"},"mode":{"type":"string"},"precondition_token":{"description":"PreconditionToken is the opaque token from the preview this write was\nconfirmed against (Spec 091 FR-005). When present, the core rechecks it\nat write time and responds 409 with action \"precondition_failed\" —\nwriting nothing — if the config or the entry MCPProxy would write has\ndrifted since; the caller then re-previews instead of retrying. Absent\nmeans exactly the pre-091 behavior. A replace-classified flow sends this\nTOGETHER with force=true: the token, not the absence of force, is the\noverwrite safety.","type":"string"},"profile":{"description":"Profile, Mode and Keyless are the client-credential intent (Spec 108\nFR-024). Profile names the profile the client's credential binds to;\n\"\" is the built-in All servers scope. Omitted means \"not specified\": a\nfresh credential defaults to All servers and a reconnect keeps the\nclient's existing binding (a reconnect never silently widens a locked\nclient). Mode is locked|switchable (default locked for a named profile,\nswitchable for All servers). Keyless writes a credential-less entry and\nis only possible while require_mcp_auth is off; it cannot carry a\nprofile or mode.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.CreateClientRequest":{"properties":{"display_name":{"type":"string"},"expires_in":{"description":"ExpiresIn is a duration like 30d or 720h; default and cap are 365 days.","type":"string"},"id":{"type":"string"},"mode":{"type":"string"},"profile":{"type":"string"},"purpose":{"description":"Purpose is the stated, unenforced task brief (Spec 115), at most 500\ncharacters; screened for secrets before anything is stored.","type":"string"}},"type":"object"},"httpapi.CreateClientResponse":{"properties":{"client":{"$ref":"#/components/schemas/httpapi.clientPresence"},"credential":{"type":"string"},"snippet":{"$ref":"#/components/schemas/httpapi.ClientSnippet"}},"type":"object"},"httpapi.EffectiveToolsData":{"properties":{"counts":{"$ref":"#/components/schemas/runtime.EffectiveCounts"},"profile":{"type":"string"},"stale_classification_reasons":{"additionalProperties":{"type":"string"},"description":"StaleClassificationReasons maps each stale classify entry to why it no\nlonger applies: profile.StaleClassificationAnnotated or\nprofile.StaleClassificationMissing. It is computed over the UNFILTERED\ntool set, so a server or reason filter never changes it. Administrators\nonly.","type":"object"},"stale_classifications":{"description":"StaleClassifications lists classify entries for tools that are now\nannotated or no longer exist (FR-005). Administrators only.","items":{"type":"string"},"type":"array","uniqueItems":false},"tools":{"items":{"$ref":"#/components/schemas/runtime.EffectiveTool"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.EnvFieldPreview":{"properties":{"empty_or_placeholder":{"type":"boolean"},"name":{"type":"string"},"secret_like":{"type":"boolean"},"value_present":{"type":"boolean"}},"type":"object"},"httpapi.FinalizeClientResponse":{"properties":{"client":{"$ref":"#/components/schemas/httpapi.clientPresence"},"rotation":{"$ref":"#/components/schemas/httpapi.RotationState"}},"type":"object"},"httpapi.ForgetClientResponse":{"properties":{"disconnect_error":{"type":"string"},"disconnected":{"type":"boolean"},"revoked":{"type":"string"}},"type":"object"},"httpapi.GuardBinding":{"properties":{"client_id":{"type":"string"},"mode":{"type":"string"},"profile":{"type":"string"},"token_name":{"type":"string"}},"type":"object"},"httpapi.GuardFixOption":{"properties":{"kind":{"type":"string"},"target":{"type":"string"}},"type":"object"},"httpapi.HeaderFieldPreview":{"properties":{"empty_or_placeholder":{"type":"boolean"},"name":{"type":"string"},"secret_like":{"type":"boolean"},"value_present":{"type":"boolean"}},"type":"object"},"httpapi.ImportFromPathRequest":{"properties":{"format":{"description":"Optional format hint","type":"string"},"path":{"description":"File path to import from","type":"string"},"rename":{"additionalProperties":{"type":"string"},"description":"Rename maps a server name → new name. Applied after parsing so the\ncaller can disambiguate cross-source name collisions (Spec 046 v2 —\ne.g. \"mcpproxy\" → \"mcpproxy_claude_code\"). Keys are matched against\neither the raw source name (OriginalName) or the sanitized name shown\nin the preview (Server.Name); these differ for names that need\nsanitizing (e.g. \"Figma Desktop\" → \"Figma_Desktop\"). Keys not present\nin the imported set are ignored.","type":"object"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportRequest":{"properties":{"allow_paste_fallback":{"description":"AllowPasteFallback opts into detecting a bare URL or a single command\nline (FR-064) when Format/format detection would otherwise fail — see\nconfigimport.ImportOptions.AllowPasteFallback for why this must stay\nopt-in (review round 4 F-E). Only the interactive Paste tab sets this;\nevery other caller of this endpoint (the general \"Import config\"\npanel, or a direct API call) leaves it false and gets a clear\n\"unable to detect configuration format\" error for a plain one-liner\ninstead of it being silently guessed at and, on apply, added as a\nreal server with no confirmation step.","type":"boolean"},"content":{"description":"Raw JSON or TOML content","type":"string"},"env_override":{"additionalProperties":{"type":"string"},"description":"EnvOverride/HeaderOverride (Spec 109 FR-064/065, PR review round 4\nF-A/F-D fix): the Paste tab's per-field Value/Secret edits — a plain\nvalue the user typed, or a keyring ref path if they chose Secret —\nkeyed by field name. Applied to the matching imported server's\nEnv/Headers ONLY when preview=false, directly on the server this\nrequest's own raw Content parses to server-side. This is what lets\nthe apply call carry the user's edited env/header values without\never round-tripping the redacted preview: url/command/args on apply\nalways come from re-parsing Content here, never from a client-held\npreview response, so a credential embedded in a URL query param or\nargv flag (which the preview necessarily redacted for display) is\nnever overwritten with the masked placeholder.","type":"object"},"format":{"description":"Optional format hint","type":"string"},"header_override":{"additionalProperties":{"type":"string"},"type":"object"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportResponse":{"properties":{"failed":{"items":{"$ref":"#/components/schemas/configimport.FailedServer"},"type":"array","uniqueItems":false},"format":{"type":"string"},"format_name":{"type":"string"},"imported":{"items":{"$ref":"#/components/schemas/httpapi.ImportedServerResponse"},"type":"array","uniqueItems":false},"skipped":{"items":{"$ref":"#/components/schemas/configimport.SkippedServer"},"type":"array","uniqueItems":false},"summary":{"$ref":"#/components/schemas/configimport.ImportSummary"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportedServerResponse":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"env":{"items":{"$ref":"#/components/schemas/httpapi.EnvFieldPreview"},"type":"array","uniqueItems":false},"fields_skipped":{"items":{"type":"string"},"type":"array","uniqueItems":false},"headers":{"items":{"$ref":"#/components/schemas/httpapi.HeaderFieldPreview"},"type":"array","uniqueItems":false},"name":{"type":"string"},"original_name":{"type":"string"},"protocol":{"type":"string"},"source_format":{"type":"string"},"summary":{"description":"Summary, Tags, Env and Headers are the Spec 109 FR-064 preview\nenrichment (contracts/rest-api.md \"Import preview\"). Summary and Tags\nare built from the already-redacted URL/Command/Args above, so a\nsecret embedded in argv or a URL query never reaches Summary either.\nEnv/Headers never carry the raw value — only its presence and two\nbooleans a surface uses to default the Value/Secret toggle (FR-065).","type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.IsolationRequest":{"description":"Isolation carries per-server Docker isolation overrides (enabled,\nmode_override, image, network_mode, extra_args, working_dir). A nil\npointer means \"do not touch isolation config\". A present object is\napplied field-by-field ON TOP of the persisted overrides, so omitting a\nfield leaves it alone; clear an individual override by sending it\nexplicitly (` + "`" + `\"enabled\": null` + "`" + `, ` + "`" + `\"image\": \"\"` + "`" + `).","properties":{"enabled":{"description":"Enabled exists ONLY to detect and reject an echoed-back read. It is the\neffective state on the read surface and is never writable; see validate().","type":"boolean"},"enabled_override":{"description":"EnabledOverride is the tri-state per-server override — the RAW value, the\nsame one reads return as ` + "`" + `enabled_override` + "`" + `. It has THREE meaningful wire\nstates, and collapsing them is what silently un-isolated servers\n(GH #1142):\n - absent → leave the persisted override untouched\n - null → clear the override, back to inheriting the global\n - true / false → set an explicit opt-in / opt-out","type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"mode_override":{"description":"ModeOverride sets ` + "`" + `isolation.mode` + "`" + ` (\"docker\" | \"sandbox\" | \"none\").\nnil leaves the persisted value alone; an empty string clears it. An\nunrecognized value is rejected with a 400 rather than persisted.","type":"string"},"network_mode":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.OnboardingMarkRequest":{"properties":{"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value. The stored enum is wider (Spec 080\nFR-001): a \"skipped\" request for a previously untouched connect step\nis upgraded server-side to \"completed_external\" when the install\nshows positive evidence of an external connection (Spec 080 FR-002).\n\"completed_external\" is NOT accepted from clients — it must never be\npersisted without that server-verified evidence (edge case: \"never\nguess completed_external without positive evidence\").","type":"string"},"connected_client_id":{"description":"ConnectedClientID records a successful connect write for this client id\n(Spec 109-b FR-042, review round 6). The REST connect endpoint\n(POST /api/v1/connect/{client}) already records this itself on success;\nthis field exists so ` + "`" + `mcpproxy connect` + "`" + ` — which writes the client's\nconfig file directly, without going through that endpoint, so the\ncommand still works when no daemon is running — can relay the same\nevent to a daemon that IS running, keeping ClientConnectedAt in sync\nacross both surfaces. Must be a known id from the fixed connect client\nregistry (internal/connect.GetAllClients); any other value is rejected,\nmatching the field's \"bounded by the registry\" invariant\n(data-model.md §7).","type":"string"},"disconnected_client_id":{"description":"DisconnectedClientID relays a successful local CLI disconnect to a running\ndaemon, which cannot observe the CLI's direct config-file edit itself.\nLike ConnectedClientID, it is bounded to the fixed client registry.","type":"string"},"engaged":{"description":"Engaged marks the wizard as engaged (completed or explicitly skipped).\nOnce true, the wizard does not auto-show again.","type":"boolean"},"mark_shown":{"description":"MarkShown records the wizard's first display time if not already set.","type":"boolean"},"server_step_status":{"description":"ServerStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value.","type":"string"}},"type":"object"},"httpapi.OnboardingStateResponse":{"properties":{"configured_server_count":{"description":"ConfiguredServerCount is the number of upstream MCP servers configured\nin mcpproxy (counts both enabled and disabled).","type":"integer"},"connected_client_count":{"description":"ConnectedClientCount is the number of supported clients currently\npointing at mcpproxy.","type":"integer"},"connected_client_ids":{"description":"ConnectedClientIDs are the identifiers of supported clients currently\npointing at mcpproxy. Drawn exclusively from the fixed adapter table —\nuser-entered values never appear here.","items":{"type":"string"},"type":"array","uniqueItems":false},"first_mcp_client_ever":{"description":"FirstMCPClientEver is true once any MCP client has successfully completed\nan ` + "`" + `initialize` + "`" + ` round-trip with this mcpproxy. Sourced from the Spec 044\nactivation bucket. Drives the Verify tab's \"green check\" state.","type":"boolean"},"has_configured_server":{"description":"HasConfiguredServer is true if at least one upstream MCP server is\nconfigured (regardless of current connection health).","type":"boolean"},"has_connected_client":{"description":"HasConnectedClient is true if at least one supported AI client currently\nhas mcpproxy registered in its config.","type":"boolean"},"has_usable_server":{"description":"HasUsableServer is true once at least one enabled, non-quarantined\nserver has usable health and at least one approved (non-disabled) tool.\nThis is the real \"the wizard has something to try\" signal:\nHasConfiguredServer only means a server entry exists, even while every\none of them sits quarantined, requires sign-in, or has zero approved\ntools. The Servers step and Setup badge use this instead of\nHasConfiguredServer, which is kept above for compatibility.\nHealthStatus.Usable is the shared\nreadiness contract used by every UI surface (Spec 109-c, tasks.md T051).","type":"boolean"},"incomplete_tab_count":{"description":"IncompleteTabCount is the number of wizard tabs whose state is incomplete.\nDrives the sidebar Setup entry's badge. Formula:\n +1 if HasConnectedClient == false\n +1 if HasUsableServer == false\n +1 if FirstMCPClientEver == false","type":"integer"},"mcp_clients_seen_ever":{"description":"MCPClientsSeenEver is the capped list of recognized client names that\nhave ever called this mcpproxy. Names come from the MCP ` + "`" + `initialize` + "`" + `\npayload's ` + "`" + `clientInfo.name` + "`" + ` field, sanitized. Surfaces on the Verify tab\nso the user can see whether their real IDE — not a test client — has\nconnected.","items":{"type":"string"},"type":"array","uniqueItems":false},"should_show_wizard":{"description":"ShouldShowWizard is the derived flag the frontend uses to decide\nwhether to auto-show. True when not engaged and IncompleteTabCount \u003e 0\n(Spec 046 v2 — semantics widened to also count the Verify tab).","type":"boolean"},"state":{"$ref":"#/components/schemas/storage.OnboardingState"},"usable_servers":{"description":"UsableServers lists the names behind HasUsableServer, for the Verify\nstep's suggested-prompt generator (FR-042): prompts are only built from\ntools of servers in this list, never from a quarantined or toolless one.","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ProfileConflictResponse":{"properties":{"code":{"type":"string"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"description":"Always false","type":"boolean"},"used_by":{"$ref":"#/components/schemas/httpapi.ProfileUsedByData"}},"type":"object"},"httpapi.ProfileDeleteData":{"properties":{"anonymous_profile_moved_to":{"type":"string"},"deleted":{"type":"string"},"moved":{"$ref":"#/components/schemas/runtime.MovedRefs"}},"type":"object"},"httpapi.ProfileListData":{"properties":{"anonymous_profile":{"type":"string"},"profiles":{"items":{"$ref":"#/components/schemas/runtime.ProfileView"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ProfileRenameData":{"properties":{"moved":{"$ref":"#/components/schemas/runtime.MovedRefs"},"profile":{"$ref":"#/components/schemas/runtime.ProfileView"}},"type":"object"},"httpapi.ProfileTryData":{"properties":{"hidden":{"items":{"$ref":"#/components/schemas/runtime.TryHidden"},"type":"array","uniqueItems":false},"hidden_by_profile":{"type":"integer"},"hidden_truncated":{"type":"boolean"},"results":{"items":{"additionalProperties":{},"type":"object"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ProfileUsedByData":{"properties":{"anonymous_profile":{"type":"boolean"},"clients":{"items":{"$ref":"#/components/schemas/runtime.UsedByClient"},"type":"array","uniqueItems":false},"tokens":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ProfileViewData":{"properties":{"blocked_24h":{"type":"integer"},"calls_24h":{"type":"integer"},"code_execution":{"type":"boolean"},"description":{"type":"string"},"effective_code_execution":{"type":"boolean"},"effective_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"effective_unannotated":{"type":"string"},"is_legacy":{"type":"boolean"},"management_tools":{"type":"boolean"},"max_tier":{"type":"string"},"name":{"type":"string"},"servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"switchable_to":{"items":{"type":"string"},"type":"array","uniqueItems":false},"title":{"type":"string"},"tool_count":{"description":"ToolCount is the v2 field: indexed tools on the effective servers.\nDeprecated: use tool_counts.","type":"integer"},"tool_counts":{"$ref":"#/components/schemas/runtime.ToolCounts"},"tools":{"$ref":"#/components/schemas/config.ProfileToolRules"},"unannotated":{"type":"string"},"used_by":{"$ref":"#/components/schemas/runtime.UsedBy"}},"type":"object"},"httpapi.ProfileWriteData":{"properties":{"profile":{"$ref":"#/components/schemas/runtime.ProfileView"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.PutClientBindingRequest":{"properties":{"mode":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.RenameProfileRequest":{"properties":{"new_name":{"type":"string"}},"type":"object"},"httpapi.RotateClientRequest":{"properties":{"precondition_token":{"description":"PreconditionToken is the token of GET /connect/{client}/preview; it\nbinds a supported client's rotation to what the preview showed.","type":"string"}},"type":"object"},"httpapi.RotateClientResponse":{"properties":{"client":{"$ref":"#/components/schemas/httpapi.clientPresence"},"connect":{"$ref":"#/components/schemas/connect.ConnectResult"},"credential":{"type":"string"},"rotation":{"$ref":"#/components/schemas/httpapi.RotationState"},"snippet":{"$ref":"#/components/schemas/httpapi.ClientSnippet"}},"type":"object"},"httpapi.RotationState":{"properties":{"state":{"type":"string"}},"type":"object"},"httpapi.SetActiveProfileRequest":{"properties":{"active_profile":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.StaleReviewErrorResponse":{"properties":{"error":{"type":"string"},"error_code":{"type":"string"},"request_id":{"type":"string"},"stale_tools":{"$ref":"#/components/schemas/httpapi.StaleToolsDetail"},"success":{"type":"boolean"}},"type":"object"},"httpapi.StaleToolsDetail":{"properties":{"changed":{"items":{"type":"string"},"type":"array","uniqueItems":false},"missing_blocks":{"items":{"type":"string"},"type":"array","uniqueItems":false},"unreviewed":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.TryProfileRequest":{"properties":{"limit":{"type":"integer"},"profile":{"$ref":"#/components/schemas/config.ProfileConfig"},"query":{"type":"string"}},"type":"object"},"httpapi.UndoConnectRequest":{"properties":{"backup_name":{"description":"BackupName is the bare filename (filepath.Base) of the backup returned as\nbackup_path by the preceding connect — a name, never a path. Undo resolves\nthe full path server-side by joining it with the client's own config\ndirectory, so a client-supplied value can never contribute a directory\ncomponent (traversal is impossible by construction). Empty means the\nconnect created the file (no prior file existed), so undo removes it.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.UpdateFailureRequest":{"properties":{"stage":{"description":"Stage is the failure stage of the update session.","enum":["appcast","download","install","other"],"type":"string"}},"type":"object"},"httpapi.UpgradeAdminKeyHoldersRequest":{"properties":{"apply":{"type":"boolean"},"mode":{"type":"string"},"precondition_token":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.approveToolsRequest":{"properties":{"approve_all":{"type":"boolean"},"expected_hashes":{"additionalProperties":{"type":"string"},"description":"ExpectedHashes binds the approval to the reviewed definitions (Spec\n117 PR-2, #1566): tool name -\u003e the review's current_hash. Absent or\nnull is an unbound (legacy) request; {} is bound.","type":["object","null"]},"expected_incarnation":{"description":"ExpectedIncarnation binds the approval to the server the client\nreviewed (Spec 116 FR-017); 409 server_replaced on a mismatch. It is\nindependent of ExpectedHashes and the two may be sent together.","type":"integer"},"tools":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.attentionResponse":{"properties":{"count":{"type":"integer"},"generated_at":{"type":"string"},"items":{"items":{"$ref":"#/components/schemas/contracts.AttentionItem"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.clientPresence":{"properties":{"active_sessions":{"type":"integer"},"blocked_24h":{"type":"integer"},"calls_24h":{"type":"integer"},"config_path":{"type":"string"},"connected":{"type":"boolean"},"connection_unverified":{"type":"boolean"},"credential_checked_at":{"type":"string"},"credential_state":{"$ref":"#/components/schemas/profile.CredentialState"},"display_name":{"type":"string"},"display_path":{"type":"string"},"expires_at":{"type":"string"},"icon":{"type":"string"},"id":{"type":"string"},"installed":{"type":"boolean"},"issuer":{"$ref":"#/components/schemas/auth.CredentialIssuer"},"kind":{"type":"string"},"last_seen":{"type":"string"},"lease":{"type":"boolean"},"profile":{"type":"string"},"profile_missing":{"type":"boolean"},"profile_mode":{"type":"string"},"profile_source":{"description":"profile_source is pin (locked) or binding (switchable) and is empty when\ncredential_state is not client.","type":"string"},"profile_state":{"type":"string"},"profile_title":{"type":"string"},"purpose":{"type":"string"},"reload_hint":{"type":"string"},"revoked_at":{"description":"Spec 115 lifecycle fields of the client's credential record (additive):\nwhen it was revoked, who issued it, its stated (unenforced) purpose,\nwhether it is a task lease (lifetime at issue \u003c= 24h) and whether its\nprofile still exists (ok | dangling | none).","type":"string"},"rotation_pending":{"type":"boolean"},"sessions":{"items":{"$ref":"#/components/schemas/httpapi.clientSession"},"type":"array","uniqueItems":false},"state":{"type":"string"},"token_name":{"type":"string"}},"type":"object"},"httpapi.clientSession":{"properties":{"id":{"type":"string"},"last_activity":{"type":"string"},"profile":{"description":"Profile and ProfileSource are the session's latest effective resolution\n(Spec 108-e FR-033); empty on legacy sessions.","type":"string"},"profile_source":{"type":"string"},"started_at":{"type":"string"},"work_session_id":{"type":"string"}},"type":"object"},"httpapi.clientsResponse":{"properties":{"clients":{"items":{"$ref":"#/components/schemas/httpapi.clientPresence"},"type":"array","uniqueItems":false},"routing":{},"warnings":{"items":{"$ref":"#/components/schemas/httpapi.ClientWarning"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.expectedIncarnationBody":{"properties":{"expected_incarnation":{"type":"integer"}},"type":"object"},"httpapi.securityApproveRequest":{"properties":{"block":{"items":{"type":"string"},"type":"array","uniqueItems":false},"expected_hashes":{"additionalProperties":{"type":"string"},"description":"ExpectedHashes binds the approval to the reviewed definitions (Spec\n117 PR-2, #1566): tool name -\u003e the review's current_hash. Absent or\nnull is an unbound (legacy) request; {} binds to the empty snapshot.\nIndependent of ExpectedIncarnation: a request may carry both, and the\ntwo refusals stay distinct (review_out_of_date vs server_replaced).","type":["object","null"]},"expected_incarnation":{"description":"ExpectedIncarnation binds the approval to the server the client\nreviewed (Spec 116 FR-017): a remove and re-add since then is refused\nwith 409 server_replaced and the replacement stays quarantined.","type":"integer"},"force":{"type":"boolean"}},"type":"object"},"management.BulkOperationResult":{"properties":{"errors":{"additionalProperties":{"type":"string"},"description":"Map of server name to error message","type":"object"},"failed":{"description":"Number of failed operations","type":"integer"},"successful":{"description":"Number of successful operations","type":"integer"},"total":{"description":"Total servers processed","type":"integer"}},"type":"object"},"observability.HealthResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"observability.HealthStatus":{"properties":{"error":{"type":"string"},"latency":{"type":"string"},"name":{"type":"string"},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"}},"type":"object"},"observability.ReadinessResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"ready\" or \"not_ready\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"profile.AccessStepStatus":{"type":"string","x-enum-varnames":["AccessStepPass","AccessStepFail","AccessStepSkip"]},"profile.AccessSubjectKind":{"type":"string","x-enum-varnames":["AccessSubjectClient","AccessSubjectProfile","AccessSubjectToken","AccessSubjectAnonymous"]},"profile.CredentialState":{"description":"Spec 108-f ClientView additions (data-model §7). The Spec 109 fields above\nare unchanged; everything below is additive.\n\ncredential_state is what the client's connection carries (client |\nadmin_key | none | revoked | expired | unknown). The stat-only list never\nreads a config: it reports the token store, else the last on-demand\nobservation (credential_checked_at says when), else unknown.","type":"string","x-enum-varnames":["CredentialStateClient","CredentialStateAdminKey","CredentialStateNone","CredentialStateRevoked","CredentialStateExpired","CredentialStateUnknown"]},"profile.ExplainStep":{"type":"string","x-enum-varnames":["StepCredential","StepProfile","StepServerInScope","StepToolRule","StepTierCap","StepTokenPermission","StepGlobalGate","StepServerState","StepToolApproval"]},"profile.ExplainVerdict":{"type":"string","x-enum-varnames":["ExplainVerdictAllowed","ExplainVerdictBlocked","ExplainVerdictHidden"]},"profile.FixAction":{"type":"string","x-enum-varnames":["FixAllowInProfile","FixClassifyInProfile","FixAddServerToProfile","FixMoveClient","FixEditToken","FixEnableServer","FixApproveTool","FixChangeSetting","FixReconnectClient"]},"profile.WarningCode":{"type":"string","x-enum-varnames":["WarningAnonymousDeniedByBindingGuard","WarningClientHoldsAdminKey","WarningClientCredentialExpiring","WarningClientRotationPending","WarningProfileMissing","WarningClientTokenNameConflict"]},"profile.WarningSeverity":{"type":"string","x-enum-varnames":["WarningSeverityWarn","WarningSeverityInfo"]},"runtime.BindingRef":{"properties":{"client_id":{"type":"string"},"mode":{"type":"string"},"profile":{"type":"string"},"token_name":{"type":"string"}},"type":"object"},"runtime.EffectiveCounts":{"properties":{"by_reason":{"additionalProperties":{"type":"integer"},"type":"object"},"callable":{"type":"integer"},"hidden":{"type":"integer"},"visible":{"type":"integer"}},"type":"object"},"runtime.EffectiveTool":{"properties":{"access":{"$ref":"#/components/schemas/runtime.ToolAccessView"},"classification_stale":{"type":"boolean"},"intrinsic_tier":{"type":"string"},"profile_tier":{"type":"string"},"server":{"type":"string"},"tool":{"type":"string"}},"type":"object"},"runtime.ExplainProfileView":{"properties":{"name":{"type":"string"},"source":{"type":"string"}},"type":"object"},"runtime.ExplainStepView":{"properties":{"detail":{"type":"string"},"status":{"$ref":"#/components/schemas/profile.AccessStepStatus"},"step":{"$ref":"#/components/schemas/profile.ExplainStep"}},"type":"object"},"runtime.ExplainSubjectView":{"properties":{"kind":{"$ref":"#/components/schemas/profile.AccessSubjectKind"},"name":{"type":"string"}},"type":"object"},"runtime.Fix":{"properties":{"action":{"$ref":"#/components/schemas/profile.FixAction"},"label":{"type":"string"},"profile":{"description":"Profile is set for move_client only: the destination profile slug the fix\nmoves the client to. Target stays the client id (UIs navigate by it) and\nLabel carries the title, so neither can name the slug.","type":"string"},"step":{"type":"string","x-enum-varnames":["StepCredential","StepProfile","StepServerInScope","StepToolRule","StepTierCap","StepTokenPermission","StepGlobalGate","StepServerState","StepToolApproval"]},"target":{"type":"string"}},"type":"object"},"runtime.GuardFix":{"properties":{"kind":{"type":"string"},"target":{"type":"string"}},"type":"object"},"runtime.MovedRefs":{"properties":{"clients":{"items":{"type":"string"},"type":"array","uniqueItems":false},"tokens":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"runtime.ProfileView":{"properties":{"blocked_24h":{"type":"integer"},"calls_24h":{"type":"integer"},"code_execution":{"type":"boolean"},"description":{"type":"string"},"effective_code_execution":{"type":"boolean"},"effective_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"effective_unannotated":{"type":"string"},"is_legacy":{"type":"boolean"},"management_tools":{"type":"boolean"},"max_tier":{"type":"string"},"name":{"type":"string"},"servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"switchable_to":{"items":{"type":"string"},"type":"array","uniqueItems":false},"title":{"type":"string"},"tool_count":{"description":"ToolCount is the v2 field: indexed tools on the effective servers.\nDeprecated: use tool_counts.","type":"integer"},"tool_counts":{"$ref":"#/components/schemas/runtime.ToolCounts"},"tools":{"$ref":"#/components/schemas/config.ProfileToolRules"},"unannotated":{"type":"string"},"used_by":{"$ref":"#/components/schemas/runtime.UsedBy"}},"type":"object"},"runtime.ToolAccessView":{"properties":{"callable":{"type":"boolean"},"reason":{"type":"string"},"visible":{"type":"boolean"}},"type":"object"},"runtime.ToolCounts":{"properties":{"destructive":{"type":"integer"},"read":{"type":"integer"},"unannotated_hidden":{"type":"integer"},"write":{"type":"integer"}},"type":"object"},"runtime.TryHidden":{"properties":{"reason":{"type":"string"},"server":{"type":"string"},"tool":{"type":"string"}},"type":"object"},"runtime.UsedBy":{"properties":{"anonymous_profile":{"type":"boolean"},"clients":{"items":{"$ref":"#/components/schemas/runtime.UsedByClient"},"type":"array","uniqueItems":false},"tokens":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"runtime.UsedByClient":{"properties":{"id":{"type":"string"},"mode":{"type":"string"}},"type":"object"},"runtime.WarningAction":{"properties":{"kind":{"type":"string"},"target":{"type":"string"}},"type":"object"},"secureenv.EnvConfig":{"description":"Environment configuration for secure variable filtering","properties":{"allowed_system_vars":{"items":{"type":"string"},"type":"array","uniqueItems":false},"custom_vars":{"additionalProperties":{"type":"string"},"type":"object"},"enhance_path":{"description":"Enable PATH enhancement for Launchd scenarios","type":"boolean"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned upstream servers (MCP-2769). It is OFF by\ndefault and deliberately kept out of the AllowedSystemVars default list:\nproxy URLs frequently carry credentials (http://user:pass@proxy), so\nforwarding them to every stdio upstream is a credential-leak risk. When\nenabled, values are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"inherit_system_safe":{"type":"boolean"}},"type":"object"},"storage.ClientCredentialObservation":{"properties":{"at":{"type":"string"},"state":{"type":"string"}},"type":"object"},"storage.OnboardingState":{"description":"State is the persisted wizard engagement record. Engaged is true once\nthe wizard was shown and the user completed or skipped it.","properties":{"client_connected_at":{"additionalProperties":{"type":"string"},"description":"ClientConnectedAt records, per client id from the fixed connect client\nregistry (internal/connect.GetAllClients — never user input), the last\ntime a connect write succeeded for that client (Spec 109-b FR-042).\nWritten by the connect success path through UpdateOnboardingState so a\nconcurrent onboarding/mark write can never drop it. Consumed by the\nVerify step / presence layer to tell \"connected, never seen\" apart from\n\"connected seconds ago, hasn't reconnected yet\".","type":"object"},"client_credential_observed":{"additionalProperties":{"$ref":"#/components/schemas/storage.ClientCredentialObservation"},"description":"ClientCredentialObserved records, per client id, the LAST credential\nclassification an on-demand read produced (Spec 108-f FR-025, F11): what\nthe client's config held (client | admin_key | none | revoked | expired)\nand when it was read. The stat-only GET /clients listing never reads a\nconfig (Spec 075: no macOS App-Data prompt from a list), so this is how\nit still reports a client that holds the admin key across restarts. It is\nwritten only when the classification CHANGES, by every on-demand read\n(GET /clients/{id}, GET /connect/{client}, the admin-key upgrade preview,\na connect write) and deleted on disconnect. Additive: an older binary\nignores it, and a record without it reads as \"unknown\".","type":"object"},"client_disconnected_at":{"additionalProperties":{"type":"string"},"type":"object"},"client_last_seen":{"additionalProperties":{"type":"string"},"description":"ClientLastSeen records the latest MCP initialize for a recognised client\nalias. It is intentionally kept with onboarding state: presence is a\nlocal UI concern and must still work when telemetry is disabled.","type":"object"},"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"completed_external\",\n\"skipped\" (Spec 080 FR-001). \"completed_external\" records a dismissal\nwhere the connect step was untouched but the install was already\nconnected outside the wizard (CLI, ConnectModal, manual config).","type":"string"},"engaged":{"description":"Engaged is true once the wizard was shown and the user completed or\nskipped it. Once true, the wizard does not auto-show again, even if\nstate regresses (e.g. user disconnects all clients).","type":"boolean"},"engaged_at":{"description":"EngagedAt is the timestamp of completion or explicit skip.","type":"string"},"first_shown_at":{"description":"FirstShownAt is the timestamp of first wizard render.","type":"string"},"server_step_status":{"description":"ServerStepStatus is one of: \"\", \"completed\", \"skipped\".","type":"string"}},"type":"object"},"telemetry.FeedbackContext":{"properties":{"arch":{"type":"string"},"connected_server_count":{"type":"integer"},"edition":{"type":"string"},"os":{"type":"string"},"routing_mode":{"type":"string"},"server_count":{"type":"integer"},"version":{"type":"string"}},"type":"object"},"telemetry.FeedbackRequest":{"properties":{"category":{"description":"bug, feature, other","type":"string"},"context":{"$ref":"#/components/schemas/telemetry.FeedbackContext"},"email":{"type":"string"},"message":{"type":"string"}},"type":"object"},"telemetry.FeedbackResponse":{"properties":{"error":{"type":"string"},"issue_url":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}},"securitySchemes":{"ApiKeyAuth":{"description":"API key authentication via query parameter. Use ?apikey=your-key","in":"query","name":"apikey","type":"apiKey"}}}, + "components": {"schemas":{"auth.CredentialIssuer":{"properties":{"actor_kind":{"type":"string"},"actor_name":{"type":"string"},"surface":{"type":"string"}},"type":"object"},"config.AuditLogConfig":{"description":"AuditLog configures the Spec 107 edition-neutral audit sink\n(internal/audit). nil means \"use the per-edition/per-transport\ndefault\" (EffectiveAuditLog); restart-pinned (bound at sink\nconstruction). See audit_log.go.","properties":{"compress":{"type":"boolean"},"enabled":{"type":"boolean"},"max_age_days":{"type":"integer"},"max_backups":{"type":"integer"},"max_size_mb":{"type":"integer"},"path":{"type":"string"},"stdout":{"type":"boolean"}},"type":"object"},"config.ConcurrencyDefaults":{"description":"ServerConcurrencyDefaults is scope (b) of FR-020: the blanket per-server\ndefault set inherited by every server that does not override a setting.\nAbsent (the default) = no per-server limiting unless a server configures\nit explicitly. File/API-configured only — no env scheme (FR-022).","properties":{"max_concurrent_requests":{"type":"integer"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"}},"type":"object"},"config.Config":{"properties":{"activity_cleanup_interval_min":{"description":"Background cleanup interval in minutes (default: 60)","type":"integer"},"activity_max_records":{"description":"Max records before pruning (default: 100000)","type":"integer"},"activity_max_response_size":{"description":"Response truncation limit in bytes (default: 65536)","type":"integer"},"activity_max_size_mb":{"description":"ActivityMaxSizeMB caps the total activity-log size in MB before the\noldest records are pruned. Omit the key for the 256MB default; set it to\n0 to disable the size cap.","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"advertise_upstream_servers":{"description":"AdvertiseUpstreamServers controls whether MCP clients are told the\nnames of the upstream servers they can reach — in the initialize\ninstructions and the retrieve_tools description — so agents use the\nproxied tools instead of falling back to shell CLIs. Names are always\nfiltered to the caller's profile and agent-token scope. Default true;\nset false to keep server names out of client context. Read live.","type":"boolean"},"aggregate_upstream_prompts":{"description":"AggregateUpstreamPrompts, when true, aggregates every connected upstream\nserver's advertised MCP prompts into mcpproxy's own prompts/list\n(exposed as \"\u003cserver\u003e__\u003cprompt\u003e\"). OFF by default: users are safe by\ndefault and opt in deliberately. EnablePrompts still governs the built-in\nprompts + the prompts capability; this flag gates ONLY the upstream\naggregation performed by RefreshPrompts. Hot-reloadable.","type":"boolean"},"allow_private_registry_fetch":{"description":"AllowPrivateRegistryFetch opts out of the registry SSRF guard (MCP-1076,\nCWE-918). By default (false) registry fetches refuse any host that is — or\nresolves to — a non-routable address (loopback, RFC1918/CGNAT private,\nlink-local incl. the 169.254.169.254 cloud-metadata endpoint), so a\nmalicious or typo'd registry source cannot turn the daemon into a\nrequest-forgery vector against internal services.\n\nThis opt-out is BLANKET (all-or-nothing): setting it true disables the\nguard for EVERY non-routable range at once — loopback, RFC1918/CGNAT\nprivate, link-local AND the 169.254.169.254 cloud-metadata endpoint. There\nis no way to allow only loopback; enabling it for a localhost dev registry\nalso re-opens the cloud-metadata SSRF vector. Set true ONLY when you\nintentionally run a trusted registry mirror on an internal/private address,\nideally on a host with no cloud-metadata exposure. The change takes effect\nonly on daemon (re)start or config reload.","type":"boolean"},"allow_server_add":{"type":"boolean"},"allow_server_remove":{"type":"boolean"},"anonymous_profile":{"description":"AnonymousProfile confines every caller whose request authenticates as\ncredential kind \"anonymous\" (no credential, or an unrecognised\nnon-agent token accepted by the require_mcp_auth:false back-compat\nbranch) to the named profile (Spec 108 FR-008). Empty (default) means\nunconfined, legacy anonymous behaviour. A name that does not match any\nconfigured profile resolves anonymous callers to deny-all and is\nreported as a validation warning (data-model.md §1).","type":"string"},"api_key":{"description":"Security settings","type":"string"},"audit_log":{"$ref":"#/components/schemas/config.AuditLogConfig"},"call_tool_timeout":{"type":"string"},"check_server_repo":{"description":"Repository detection settings","type":"boolean"},"code_execution_max_parallel":{"description":"Default concurrency for call_tools() batches (1-32, default: 8)","type":"integer"},"code_execution_max_tool_calls":{"description":"Max tool calls per execution (0 = unlimited, default: 0)","type":"integer"},"code_execution_pool_size":{"description":"JavaScript runtime pool size (default: 10)","type":"integer"},"code_execution_timeout_ms":{"description":"Timeout in milliseconds (default: 120000, max: 600000)","type":"integer"},"data_dir":{"type":"string"},"debug_search":{"type":"boolean"},"direct_tool_response_mode":{"description":"DirectToolResponseMode selects the serialization of the DIRECT\nenumeration surface (Spec 102). Valid values: \"\" (= full), \"full\"\n(default: today's schema-bearing entries), \"deferred\" (description +\ncompact signature, with a minimal permissive input schema; upstream\ninputSchema and outputSchema are stripped and recovered on demand via\ndescribe_tool).\n\nDeliberately NOT an extension of tool_response_mode: reusing that axis\nwould silently change /mcp/all output for every deployment already\nrunning compact, which FR-015 forbids. Serialization-only — it never\nchanges WHICH tools are listed, only how (FR-008). Hot-reloadable.","type":"string"},"disable_management":{"type":"boolean"},"docker_isolation":{"$ref":"#/components/schemas/config.DockerIsolationConfig"},"docker_recovery":{"$ref":"#/components/schemas/config.DockerRecoveryConfig"},"enable_code_execution":{"description":"Code execution settings","type":"boolean"},"enable_prompts":{"description":"Prompts settings","type":"boolean"},"enable_socket":{"description":"Enable Unix socket/named pipe for local IPC (default: true)","type":"boolean"},"enable_tray":{"description":"Deprecated: EnableTray is unused and has no runtime effect. Kept for backward compatibility.","type":"boolean"},"environment":{"$ref":"#/components/schemas/secureenv.EnvConfig"},"features":{"$ref":"#/components/schemas/config.FeatureFlags"},"forward_client_headers":{"description":"ForwardClientHeaders is the global switch for client header forwarding\n(Spec 112): copying allowlisted headers from the inbound /mcp request into\nthe upstream tools/call request. nil (absent) means enabled; because every\nper-server forward_headers allowlist is empty by default, the default\nforwards nothing. false disables forwarding for every server. Read via\nIsClientHeaderForwardingEnabled(); MCPPROXY_FORWARD_CLIENT_HEADERS\noverrides it for the process. Distinct from ForwardedHeaders/trusted_proxies\n(Spec 107), which trusts inbound X-Forwarded-*.","type":"boolean"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned stdio upstream servers (MCP-2769). OFF by\ndefault: proxy URLs commonly embed credentials (http://user:pass@proxy), so\nforwarding them to every upstream is a credential-leak risk. When enabled,\nvalues are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"health_check_interval":{"description":"Discovery \u0026 health-check cadence (spec 074, #608). Both are *Duration\ntri-state pointers: nil = inherit the built-in default; a pointer to 0s =\nthe loop is disabled; a positive value = that interval. Defaults live only\nin the resolvers (ResolveHealthCheckInterval / ResolveToolDiscoveryInterval)\nso an unset key behaves exactly as before this feature (SC-005). Validated\nin Validate(): health-check ∈ {0} ∪ [5s,1h]; tool-discovery ∈ {0} ∪ [30s,24h].","type":"string"},"http_idle_timeout":{"description":"HTTPIdleTimeout caps how long an idle keep-alive connection is kept open.\nUnset = 180s. \"0s\" removes the dedicated idle deadline, but net/http then\nfalls back to ReadTimeout — idle is fully unbounded only when\nhttp_read_timeout is also \"0s\". Requires a restart.","type":"string"},"http_read_timeout":{"description":"HTTPReadTimeout caps how long reading a whole request (headers + body)\nmay take. Unset = 120s; \"0s\" disables it. Requires a restart.","type":"string"},"http_write_timeout":{"description":"HTTPWriteTimeout caps how long producing a whole response may take on\nnon-streaming endpoints (REST, Web UI, health). Unset = 120s; \"0s\"\ndisables it globally. MCP and SSE /events routes are exempt by design.","type":"string"},"init_timeout":{"description":"InitTimeout is the global default deadline for an upstream's MCP\n` + "`" + `initialize` + "`" + ` handshake (MCP-3322 / GH #760). *Duration tri-state: nil =\ninherit the built-in 30s default; a positive value = that deadline. A\nper-server InitTimeout overrides this. Resolved by ResolveInitTimeout;\nvalidated to {0} ∪ [1s, 30m] in Validate(). Servers doing legitimate\nfirst-run warmup (cache/index build) before answering ` + "`" + `initialize` + "`" + ` can\nraise this so they are not killed mid-startup.","type":"string"},"instructions":{"description":"Instructions text returned in the MCP initialize response to guide AI agents.\nWhen empty, a built-in default is used that explains retrieve_tools workflow.","type":"string"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"max_concurrent_requests":{"description":"Concurrency limits (spec 093, GH #955). Scope (a) of FR-020: the GLOBAL\nAGGREGATE limiter — one proxy-wide cap on concurrently running upstream\ntool calls, with its own bounded wait queue. Tri-state pointers: absent =\nthe limiter does not exist (default, zero behavior change); an explicit 0\nmax also disables it; positive = that cap. This scope is NEVER a\nper-server inheritance source — per-server values come from\nServerConcurrencyDefaults / the per-server overrides — but a server's\neffective concurrency is bounded by BOTH its own limiter and this one.\nResolved by ResolveGlobalConcurrency; hot-reloadable; overridable via\nMCPPROXY_MAX_CONCURRENT_REQUESTS / _QUEUE_SIZE / _QUEUE_TIMEOUT (FR-022).","type":"integer"},"max_result_size_chars":{"description":"MaxResultSizeChars is advertised on every tool as\n` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; it raises Claude Code's\ninline-response ceiling from 50k to up to 500k chars. Omit the key for\nthe 500000 default; set it to 0 to disable the annotation.","type":"integer"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"observability":{"$ref":"#/components/schemas/config.ObservabilityConfig"},"output_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"output_validation":{"$ref":"#/components/schemas/config.OutputValidationConfig"},"profiles":{"description":"Profiles are optional named, server-scoped views exposed at /mcp/p/\u003cname\u003e\n(Spec 057). Absent/empty is fully supported — /mcp is unchanged and configs\nwithout this key serialize byte-identically (SC-004).","items":{"$ref":"#/components/schemas/config.ProfileConfig"},"type":"array","uniqueItems":false},"quarantine_enabled":{"description":"QuarantineEnabled controls whether quarantine is active. It gates two\nthings together:\n 1. Server-level auto-quarantine for newly added servers (issue #370).\n When true, servers added via the upstream_servers MCP tool or the\n REST API default to quarantined=true; when false, they default to\n quarantined=false. Explicit per-request values always win.\n 2. Tool-level quarantine (Spec 032): per-tool SHA-256 approval of\n tool descriptions/schemas.\nWhen nil (default), quarantine is enabled (secure by default). Set to\nexplicit false to opt out of both. Per-server SkipQuarantine still\napplies for the tool-level check on individual servers.","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"registries_locked":{"description":"RegistriesLocked is an enterprise stub knob (MCP-866): when true, runtime\nadditions of custom registries (e.g. ` + "`" + `registry add-source` + "`" + `, the REST/MCP\nadd-source surface) are rejected so an administrator can pin the discovery\nsources. Built-in defaults are unaffected. Documented but otherwise inert\nbeyond the add-source rejection.","type":"boolean"},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"reveal_secret_headers":{"description":"RevealSecretHeaders, when true, disables the redaction of the\nsecret-bearing server fields — sensitive header values (Authorization,\nX-API-Key, Cookie, …), env-var secrets, and URL query credentials — in\nresponses from the ` + "`" + `upstream_servers` + "`" + ` MCP tool, the ` + "`" + `/api/v1/servers` + "`" + `\nREST API, and the SSE event stream. It also lets URL secrets echoed\ninto last_error / health.detail through unscrubbed.\n\nDefault false — sensitive values are surfaced masked as\n` + "`" + `••••\u003clast2\u003e (\u003cN\u003e chars)` + "`" + ` (error strings use ` + "`" + `***REDACTED***` + "`" + `) so an\nMCP agent cannot read Bearer tokens / API keys / URL secrets out of\nanother upstream's config (PR #425, issue #872). ${env:…}/${keyring:…}\nreferences are labels, not secrets, and pass through unchanged.\n\nThe Web UI / macOS tray edit forms work without seeing the real\nvalues: PATCH /api/v1/servers/{id} deep-merges (omitted keys are\npreserved, see ` + "`" + `headers_remove` + "`" + ` / ` + "`" + `env_remove` + "`" + ` for explicit\ndeletes), so clients compute a diff and only send the keys that\nactually changed. Redacted-but-unchanged values never round-trip\n— the backend keeps the real string. Set this to true if a\ndownstream tool genuinely needs raw values in the response.","type":"boolean"},"routing_mode":{"description":"Routing mode (Spec 031): how MCP tools are exposed to clients\nValid values: \"retrieve_tools\" (default), \"direct\", \"code_execution\"","type":"string"},"security":{"$ref":"#/components/schemas/config.SecurityConfig"},"sensitive_data_detection":{"$ref":"#/components/schemas/config.SensitiveDataDetectionConfig"},"server_concurrency_defaults":{"$ref":"#/components/schemas/config.ConcurrencyDefaults"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_call_max_records_per_server":{"description":"Calls retained per server (default: 1000)","type":"integer"},"tool_call_max_response_size":{"description":"Bounds for the per-server tool-call history behind GET /api/v1/tool-calls\n(#1176). It is a recent-debugging window, not an audit log — the activity\nlog is the durable record — and it kept every upstream response whole,\nper server, forever. A non-positive value means \"use the default\", not\n\"disable\": this store must never be unbounded again, so there is\ndeliberately no off switch.","type":"integer"},"tool_discovery_interval":{"type":"string"},"tool_response_limit":{"type":"integer"},"tool_response_mode":{"description":"Tool response mode (Spec 085): how retrieve_tools serializes results.\nValid values: \"\" (= full), \"full\" (default: today's schema-bearing\nentries), \"compact\" (signature + first-sentence entries). Orthogonal to\nrouting_mode — routing_mode selects the tool SURFACE, this selects the\nSERIALIZATION within the retrieve_tools surface. Serialization-only: it\nnever affects the query, ranking, or result set. Hot-reloadable.","type":"string"},"tool_response_session_risk_warning":{"description":"ToolResponseSessionRiskWarning controls whether the prose ` + "`" + `warning` + "`" + ` field\nis included in the ` + "`" + `session_risk` + "`" + ` object returned by ` + "`" + `retrieve_tools` + "`" + `.\nThe structured fields (level, lethal_trifecta, has_open_world_tools, etc.)\nare always included. Default: false (quiet for LLM clients) — see issue #406.\nMost tools lack annotations, so the MCP-spec defaults treat them as fully\npermissive across all three risk axes, which makes the prose warning fire\non almost every call and wastes tokens.","type":"boolean"},"tools_limit":{"type":"integer"},"toon_min_savings_pct":{"description":"ToonMinSavingsPct is the minimum byte-savings percentage (validated\n1-90; 0/unset → 15) the complete TOON emission (marker + hint + body)\nmust achieve over the exact passthrough emission for adaptive mode to\nencode a block. Byte savings approximate token savings for the tabular\npayload class; the spec-083 profiler reports true token deltas.\nGlobal-only (no per-server override, FR-001).","type":"integer"},"toon_output":{"description":"ToonOutput selects the TOON encoding mode for call_tool_* result text\nblocks (spec 084): \"off\" (default — responses byte-identical to\npre-feature behavior), \"adaptive\" (encode only tabular-uniform payloads\nthat beat compact JSON by ToonMinSavingsPct), or \"always\"\n(benchmark/debug only — encodes every JSON-parseable block and can\nINCREASE token cost). Per-server override: ServerConfig.ToonOutput.\nResolved by ResolveToonOutput; hot-reloadable.","type":"string"},"top_k":{"description":"Deprecated: TopK is superseded by ToolsLimit and has no runtime effect. Kept for backward compatibility.","type":"integer"},"tray_endpoint":{"description":"Tray endpoint override (unix:// or npipe://)","type":"string"},"trusted_hosts":{"description":"TrustedHosts lists non-loopback Host header values accepted on loopback\nlisteners (GH #898). DNS-rebinding protection rejects requests whose Host\nheader is not a loopback address when mcpproxy listens on loopback; a\nreverse proxy (nginx → 127.0.0.1) forwarding the public domain in Host\ntrips it. Entries are hostnames, case-insensitive; an entry without a\nport matches any port, with a port it must match exactly; a leading dot\n(\".example.com\") is a subdomain wildcard. The single entry \"*\" disables\nHost and Origin validation entirely. The same list also validates the\nOrigin header when present (MCP spec DNS-rebinding defense). Empty\n(default) keeps full protection. Env override: MCPPROXY_TRUSTED_HOSTS\n(comma-separated).","items":{"type":"string"},"type":"array","uniqueItems":false},"trusted_proxies":{"description":"TrustedProxies lists the CIDRs or IP addresses whose X-Forwarded-For /\nX-Real-IP / X-Forwarded-Proto / X-Forwarded-Host headers are believed\n(Spec 107 FR-027). Empty (default) trusts nobody. Edition-neutral, live\n(hot-reloadable). Env override: MCPPROXY_TRUSTED_PROXIES (comma-separated).\nThe one reader is ForwardedHeaders; validation is validateTrustedProxies.","items":{"type":"string"},"type":"array","uniqueItems":false},"update_check":{"$ref":"#/components/schemas/config.UpdateCheckConfig"}},"type":"object"},"config.CustomPattern":{"properties":{"category":{"description":"Category (defaults to \"custom\")","type":"string"},"keywords":{"description":"Keywords to match (mutually exclusive with Regex)","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Unique identifier for this pattern","type":"string"},"regex":{"description":"Regex pattern (mutually exclusive with Keywords)","type":"string"},"severity":{"description":"Risk level: critical, high, medium, low","type":"string"}},"type":"object"},"config.DeepScanConfig":{"description":"DeepScan is the opt-in \"deep scan\" layer (Spec 077 US3). It subsumes the\ndeprecated top-level scanner_fetch_package_source / scanner_disable_no_new_privileges\nkeys (migrated on load) and gates the heavy Docker-based scanners + source\nextraction. Disabled by default (FR-006): only the deterministic in-process\nbaseline scanner runs. A deep-scan failure NEVER changes the baseline verdict\n(FR-007/FR-008).","properties":{"disable_no_new_privileges":{"description":"DisableNoNewPrivileges, when true, omits the ` + "`" + `--security-opt\nno-new-privileges` + "`" + ` flag from scanner container runs (snap-docker/AppArmor\nescape hatch). Absorbs the deprecated top-level\nscanner_disable_no_new_privileges. Default false.","type":"boolean"},"enabled":{"description":"Enabled is the master opt-in for the heavy layer (FR-006). Default false.","type":"boolean"},"fetch_package_source":{"description":"FetchPackageSource controls whether the scanner fetches the PUBLISHED\nsource of package-runner servers (npx/uvx) — without executing it — when\nno local source is available. Absorbs the deprecated top-level\nscanner_fetch_package_source. Default (nil) is ENABLED within deep scan.","type":"boolean"},"scanners":{"description":"Scanners optionally restricts which deep scanners may run under the\numbrella (by scanner id). Empty ⇒ all enabled deep scanners are eligible.","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.DockerIsolationConfig":{"description":"Docker isolation settings","properties":{"cpu_limit":{"description":"CPU limit for containers","type":"string"},"default_images":{"additionalProperties":{"type":"string"},"description":"Map of runtime type to Docker image","type":"object"},"enable_cache_volume":{"description":"Mount shared cache volumes for faster restarts (default: true)","type":"boolean"},"enabled":{"description":"Global enable/disable for Docker isolation (legacy; superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments","items":{"type":"string"},"type":"array","uniqueItems":false},"log_driver":{"description":"Docker log driver (default: json-file)","type":"string"},"log_max_files":{"description":"Maximum number of log files (default: 3)","type":"string"},"log_max_size":{"description":"Maximum size of log files (default: 100m)","type":"string"},"memory_limit":{"description":"Memory limit for containers","type":"string"},"mode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"network_mode":{"description":"Docker network mode (default: bridge)","type":"string"},"registry":{"description":"Custom registry (defaults to docker.io)","type":"string"},"timeout":{"description":"Container startup timeout","type":"string"}},"type":"object"},"config.DockerRecoveryConfig":{"description":"Docker recovery settings","properties":{"enabled":{"description":"Enable Docker recovery monitoring (default: true)","type":"boolean"},"max_retries":{"description":"Maximum retry attempts (0 = unlimited)","type":"integer"},"notify_on_failure":{"description":"Show notification on recovery failure (default: true)","type":"boolean"},"notify_on_retry":{"description":"Show notification on each retry (default: false)","type":"boolean"},"notify_on_start":{"description":"Show notification when recovery starts (default: true)","type":"boolean"},"notify_on_success":{"description":"Show notification on successful recovery (default: true)","type":"boolean"},"persistent_state":{"description":"Save recovery state across restarts (default: true)","type":"boolean"}},"type":"object"},"config.FeatureFlags":{"description":"Deprecated: Features flags are unused and have no runtime effect. Kept for backward compatibility.","properties":{"enable_async_storage":{"type":"boolean"},"enable_caching":{"type":"boolean"},"enable_contract_tests":{"type":"boolean"},"enable_debug_logging":{"description":"Development features","type":"boolean"},"enable_docker_isolation":{"type":"boolean"},"enable_event_bus":{"type":"boolean"},"enable_health_checks":{"type":"boolean"},"enable_metrics":{"type":"boolean"},"enable_oauth":{"description":"Security features","type":"boolean"},"enable_observability":{"description":"Observability features","type":"boolean"},"enable_quarantine":{"type":"boolean"},"enable_runtime":{"description":"Runtime features","type":"boolean"},"enable_search":{"description":"Storage features","type":"boolean"},"enable_sse":{"type":"boolean"},"enable_tracing":{"type":"boolean"},"enable_tray":{"type":"boolean"},"enable_web_ui":{"description":"UI features","type":"boolean"}},"type":"object"},"config.IntentDeclarationConfig":{"description":"Intent declaration settings (Spec 018)","properties":{"strict_server_validation":{"description":"StrictServerValidation controls whether server annotation mismatches\ncause rejection (true) or just warnings (false).\nDefault: true (reject mismatches)","type":"boolean"}},"type":"object"},"config.IsolationConfig":{"description":"Per-server isolation settings","properties":{"enabled":{"description":"Enable Docker isolation for this server (nil = inherit global; legacy, superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments for this server","items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"description":"Custom Docker image (overrides default)","type":"string"},"log_driver":{"description":"Docker log driver override for this server","type":"string"},"log_max_files":{"description":"Maximum number of log files override","type":"string"},"log_max_size":{"description":"Maximum size of log files override","type":"string"},"mode":{"$ref":"#/components/schemas/config.IsolationMode"},"network_mode":{"description":"Custom network mode for this server","type":"string"},"working_dir":{"description":"Custom working directory in container","type":"string"}},"type":"object"},"config.IsolationMode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"config.LogConfig":{"description":"Logging configuration","properties":{"compress":{"type":"boolean"},"enable_console":{"type":"boolean"},"enable_file":{"type":"boolean"},"filename":{"type":"string"},"json_format":{"type":"boolean"},"level":{"type":"string"},"log_dir":{"description":"Custom log directory","type":"string"},"max_age":{"description":"days","type":"integer"},"max_backups":{"description":"number of backup files","type":"integer"},"max_size":{"description":"MB","type":"integer"}},"type":"object"},"config.MetricsExporterConfig":{"description":"Metrics gates the Prometheus /metrics scrape endpoint (MCP-32). Disabled\nby default — operators opt in for k8s/enterprise deployments.","properties":{"enabled":{"description":"Enabled exposes /metrics on the existing HTTP listener when true.\nThe endpoint is admin-authenticated (SEC-07): scrapers must present the\nglobal API key, via X-API-Key or an Authorization: Bearer header.","type":"boolean"}},"type":"object"},"config.OAuthConfig":{"description":"OAuth configuration (keep even when empty to signal OAuth requirement)","properties":{"auth_server_metadata_url":{"type":"string"},"authorization_endpoint":{"description":"Spec 113-b: optional per-server discovery overrides. Each is an absolute\nhttps URL (http only on a loopback host) with no query, fragment or\nuserinfo; validated at write time only (ValidateOAuthEndpointOverrides).\nThe endpoint overrides rewrite the matching fields of the authorization\nserver metadata mcp-go reads; AuthServerMetadataURL replaces discovery.","type":"string"},"callback_base_url":{"type":"string"},"client_id":{"type":"string"},"client_secret":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"description":"Additional OAuth parameters (e.g., RFC 8707 resource)","type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_uri":{"type":"string"},"registration_endpoint":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"token_endpoint":{"type":"string"}},"type":"object"},"config.ObservabilityConfig":{"description":"Observability settings (Spec 069): usage aggregate cache/persistence cadence.","properties":{"metrics":{"$ref":"#/components/schemas/config.MetricsExporterConfig"},"tracing":{"$ref":"#/components/schemas/config.TracingExporterConfig"},"usage_cache_ttl":{"description":"UsageCacheTTL bounds the freshness of the usage endpoint's read cache for\nwide windows (FR-005). Default 5s.","type":"string"},"usage_persist_interval":{"description":"UsagePersistInterval is how often the actor-owned usage aggregate snapshot\nis flushed to storage. Default 30s.","type":"string"}},"type":"object"},"config.OutputSanitisationConfig":{"description":"Output sanitisation settings (Spec 054 Track B)","properties":{"max_redactions":{"description":"cap on redactions per response; default 100","type":"integer"},"response_action":{"description":"\"spotlight\" | \"redact\" | \"block\"; default \"spotlight\"","type":"string"},"spotlight_untrusted":{"description":"wrap untrusted output in spotlight markers; default true","type":"boolean"},"strip_classes":{"description":"classes to strip: ansi/c0c1/bidi/zero_width","items":{"type":"string"},"type":"array","uniqueItems":false},"strip_control_chars":{"description":"strip control-character classes; default false","type":"boolean"}},"type":"object"},"config.OutputValidationConfig":{"description":"Output-schema validation settings (Spec 056)","properties":{"max_bytes":{"description":"structured payload byte cap; default 5\u003c\u003c20","type":"integer"},"max_depth":{"description":"nesting depth cap; default 64","type":"integer"},"missing_structured_content":{"description":"\"allow\" | \"block\"; default \"allow\"","type":"string"},"mode":{"description":"\"off\" | \"warn\" | \"strict\"; default \"warn\"","type":"string"}},"type":"object"},"config.ProfileConfig":{"properties":{"code_execution":{"description":"CodeExecution: nil = inherit the global enable_code_execution gate;\nnon-nil narrows it (a profile can only narrow, never widen, FR-006).","type":"boolean"},"description":{"description":"\u003c= 500 chars (FR-001)","type":"string"},"management_tools":{"description":"ManagementTools: nil = legacy/inherit (FR-016); non-nil sets the\nprofile-level visibility of upstream_servers/quarantine_security.","type":"boolean"},"max_tier":{"description":"MaxTier caps the tier a tool may run at under this profile: \"\" (no\ncap) | \"read\" | \"write\" | \"destructive\" (FR-001).","type":"string"},"name":{"description":"slug (Spec 057 rules unchanged)","type":"string"},"servers":{"description":"references to mcpServers[].name","items":{"type":"string"},"type":"array","uniqueItems":false},"switchable_to":{"description":"SwitchableTo is the list of profile names a session under this\nprofile may ` + "`" + `set_profile` + "`" + ` into (FR-022). nil = legacy/none (research\nD6); a non-nil EMPTY list is an explicit \"none\" and must round-trip as\n` + "`" + `[]` + "`" + `, never be dropped by omitempty — hence the pointer (see the\nMarshalJSON note below, and profiles_v3_test.go's switchable_to round\ntrip case).","items":{"type":"string"},"type":"array","uniqueItems":false},"title":{"description":"display only, \u003c= 80 chars (FR-001)","type":"string"},"tools":{"$ref":"#/components/schemas/config.ProfileToolRules"},"unannotated":{"description":"Unannotated is this profile's handling of a tool whose effective\nannotations carry no tier hint: \"\" (unset, see EffectiveUnannotated) |\n\"deny\" | \"as_write\" | \"as_read\" (FR-001, FR-003).","type":"string"}},"type":"object"},"config.ProfileToolRules":{"description":"Tools holds the allow/deny/classify rule set (FR-001, FR-004, FR-005).","properties":{"allow":{"items":{"type":"string"},"type":"array","uniqueItems":false},"classify":{"additionalProperties":{"type":"string"},"type":"object"},"deny":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag for this registry (MCP-866):\nRegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom\nfor user-added registries. It is authoritatively (re)computed by the\nregistries merge from whether the ID is a shipped default — a user cannot\nclaim \"official\" by writing it into their config.","type":"string"},"requires_key":{"description":"RequiresKey marks a registry that needs an API key to be queried. When\ntrue and no key is configured, the registry is skipped/marked unavailable\nrather than failing the whole search (FR-008).","type":"boolean"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"auto_baseline_scan":{"description":"AutoBaselineScan is the kill-switch for the AUTOMATIC, informational\nPass-1 baseline scan: the free in-process TPA scan mcpproxy runs for every\nnewly admitted server (any trust mode) and, once per installation, over\npre-existing servers that have never been scanned.\n\nInformational ONLY: the resulting verdict populates the security badge and\nthe scan summary, and NEVER gates quarantine or approval. The\ntrust_mode:\"scan\" admission gate is a separate path and is unaffected by\nthis flag.\n\nDefault (nil) is ENABLED. Set to false to suppress every automatic scan\n(manual scans keep working). Env override: MCPPROXY_AUTO_BASELINE_SCAN,\nwhich wins over this field on every path.","type":"boolean"},"deep_scan":{"$ref":"#/components/schemas/config.DeepScanConfig"},"integrity_check_interval":{"type":"string"},"integrity_check_on_restart":{"type":"boolean"},"require_signed_bundle":{"description":"RequireSignedBundle makes the detached signature sidecar mandatory for\nfile-dropped bundles (FR-007). Default false (maintainer decision D2):\nexisting unsigned drops keep working, but an unsigned external bundle\nalways runs with DEGRADED coverage (scan-mode auto-approval suspended).","type":"boolean"},"runtime_read_only":{"type":"boolean"},"runtime_tmpfs_size":{"type":"string"},"scan_timeout_default":{"type":"string"},"scanner_disable_no_new_privileges":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.DisableNoNewPrivileges\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.IsDisableNoNewPrivileges. Cleared after migration.\n\nScannerDisableNoNewPrivileges, when true, omits the\n` + "`" + `--security-opt no-new-privileges` + "`" + ` flag from scanner container runs.\n\nBackground: snap-installed Docker on Ubuntu confines dockerd under the\n` + "`" + `snap.docker.dockerd` + "`" + ` AppArmor profile. When runc tries to transition\nthe container into the inner ` + "`" + `docker-default` + "`" + ` profile to exec the\nentrypoint, AppArmor refuses the transition because NO_NEW_PRIVS\nforbids privilege/profile changes on exec — the result is EPERM\n(\"operation not permitted\") and every scanner fails immediately.\n\nSet this to true ONLY on hosts hitting that incompatibility. Scanner\ncontainers still run with read-only rootfs, tmpfs /tmp, no-network by\ndefault, and read-only source mounts, so the marginal isolation loss\nis small. The preferred fix remains replacing snap docker with a\ndistro-packaged docker.","type":"boolean"},"scanner_fetch_package_source":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.FetchPackageSource\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.EffectiveFetchPackageSource. Cleared after migration.\n\nScannerFetchPackageSource controls whether the scanner fetches the\nPUBLISHED source of package-runner servers (npx/uvx) — without executing\nit — when no local source is available (no Docker container, no local\npackage cache, no working_dir). This is the primary quarantine/scan\ntarget: a quarantined-on-add server is never run locally, so without this\nthe scan degrades to tool-definitions-only (no real source-level\nanalysis). See MCP-2206.\n\nFetching uses ` + "`" + `npm pack --ignore-scripts` + "`" + ` (npm) and ` + "`" + `uv pip download` + "`" + ` /\n` + "`" + `pip download` + "`" + ` with ` + "`" + `--only-binary=:all:` + "`" + ` (Python), which only download +\nunpack archives and NEVER run install, build, or setup.py — a scanner must\nnot execute the untrusted code it is scanning. The Python\n` + "`" + `--only-binary=:all:` + "`" + ` flag is required because downloading an sdist would\ninvoke its build backend (setup.py); packages with no wheel fall back to\ntool-definitions-only instead. Extraction is hardened against path\ntraversal and decompression bombs.\n\nDefault (nil) is ENABLED. Set to false on air-gapped deployments to\nforbid the scanner's network egress; such servers then fall back to the\ntool-definitions-only scan with no regression.","type":"boolean"},"scanner_registry_url":{"type":"string"},"tpa_active_root":{"description":"TPAActiveRoot declares which signing authority's bundles may activate\n(FR-004b): \"publisher\" (default, the keys embedded in this binary) or\n\"operator\" (TPAOperatorSigners). A bundle verified by the other root is\nrefused as authority_not_active, so whoever can write the bundle path\ncannot choose the authority.","type":"string"},"tpa_bundle_path":{"description":"TPABundlePath is the filesystem path to the tpa-db scanner-bundle.json\nthe offline TPA scanner runs (spec 086 FR-019: the signature-DB location\nMUST be configuration-driven, not hardcoded). Empty (the default) runs the\ncorpus embedded in this build.\n\nEnv override: MCPPROXY_TPA_BUNDLE_PATH. Hot-reloadable — the path is\nre-read on every config.reloaded event via\nscanner.Service.ApplySecurityConfig, so a corpus refresh needs no restart.\nA configured bundle that fails to read/parse/version-check/compile is\nREFUSED and the previously active corpus stays live (fail-closed, never\nfail-empty); the reason is logged and surfaced in the security overview's\nsignature_bundle.load_error.","type":"string"},"tpa_operator_baseline":{"description":"TPAOperatorBaseline seeds the operator authority's anti-downgrade\nwatermark (FR-009b): an operator-signed bundle must carry a sequence\nABOVE it (a rollback target may sit at it). It survives a data-directory\nwipe because it lives here. Absent means the operator authority has no\nanti-downgrade floor and an active operator bundle runs DEGRADED.","type":"integer"},"tpa_operator_epoch":{"description":"TPAOperatorEpoch is the operator authority's trust epoch (FR-005a).\nBumping it (after an operator key compromise) re-baselines the operator\nwatermark and retires prior-epoch pins. Absent means 0.","type":"integer"},"tpa_operator_signers":{"description":"TPAOperatorSigners lists operator Ed25519 public keys, each the standard\nbase64 encoding of the 32 raw key bytes (FR-004). Their key ids are the\nlowercase-hex SHA-256 of those bytes. Malformed or duplicate entries are\nrejected on write and dropped (with a log) on load.","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.SensitiveDataDetectionConfig":{"description":"Sensitive data detection settings (Spec 026)","properties":{"categories":{"additionalProperties":{"type":"boolean"},"description":"Enable/disable specific detection categories","type":"object"},"custom_patterns":{"description":"User-defined detection patterns","items":{"$ref":"#/components/schemas/config.CustomPattern"},"type":"array","uniqueItems":false},"enabled":{"description":"Enable sensitive data detection (default: true)","type":"boolean"},"entropy_threshold":{"description":"Shannon entropy threshold for high-entropy detection (default: 4.5)","type":"number"},"max_payload_size_kb":{"description":"Max size to scan before truncating (default: 1024)","type":"integer"},"scan_requests":{"description":"Scan tool call arguments (default: true)","type":"boolean"},"scan_responses":{"description":"Scan tool responses (default: true)","type":"boolean"},"sensitive_keywords":{"description":"Keywords to flag","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ServerConfig":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve tool\nchanges/additions (disabling per-server rug-pull protection). Supersedes\nskip_quarantine. MCP-2930 only ACCEPTS, persists, and migrates this flag — it\nis NOT yet consulted at runtime; auto-approval is still governed by\nSkipQuarantine until the trust-baseline behavior change (MCP-2931) migrates the\nruntime consumers onto it.\nTri-state pointer (mirrors QuarantineEnabled): nil = unset (inherit/migrate\nfrom legacy skip_quarantine), explicit true/false = honored as-is so an\nexplicit auto_approve_tool_changes:false overrides a legacy skip_quarantine:true.\nRead via IsAutoApproveToolChanges().","type":"boolean"},"command":{"type":"string"},"created":{"type":"string"},"disabled_tools":{"description":"Denylist: these tools are hidden; mutually exclusive with enabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"enabled":{"type":"boolean"},"enabled_tools":{"description":"Allowlist: only these tools are exposed; mutually exclusive with disabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"env":{"additionalProperties":{"type":"string"},"type":"object"},"expose_prompts":{"description":"ExposePrompts overrides whether this server's advertised MCP prompts are\naggregated into mcpproxy's prompts/list. nil (default) inherits the\ndefault-aggregate behavior (included if the server advertises\nCapabilities.Prompts); false excludes it regardless of capability.","type":"boolean"},"forward_headers":{"description":"ForwardHeaders lists inbound MCP-client header NAMES (never values) that\nare copied into this server's upstream tools/call requests (Spec 112).\nExact names, case-insensitive, at most 32, no wildcards. Empty forwards\nnothing. Only HTTP-based transports (http, streamable-http) forward; the\ndeny list (Authorization, Host, hop-by-hop, ...) is enforced at runtime\nregardless of what is configured here.","items":{"type":"string"},"type":"array","uniqueItems":false},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"health_check_interval":{"description":"Per-server discovery \u0026 health-check overrides (spec 074). Same *Duration\ntri-state as the global keys: nil = inherit the global value (or default),\npointer to 0s = disabled for this server, positive = that interval.\nHealthCheckInterval is fully wired into the per-server health loop;\nToolDiscoveryInterval is accepted/validated and round-trips for\nforward-compat, but the periodic index sweep is governed by the global\ncadence in this iteration (see spec 074 plan §C).","type":"string"},"init_timeout":{"description":"InitTimeout overrides the global init_timeout for this server's MCP\n` + "`" + `initialize` + "`" + ` handshake deadline (MCP-3322 / GH #760). *Duration tri-state:\nnil = inherit the global value (or 30s default), positive = that deadline.\nResolved by Config.ResolveInitTimeout; validated to {0} ∪ [1s, 30m]. Raise\nthis for upstreams that do legitimate first-run warmup (e.g. caching many\nchannels/users) before responding to ` + "`" + `initialize` + "`" + `.","type":"string"},"isolation":{"$ref":"#/components/schemas/config.IsolationConfig"},"launcher_wait_timeout":{"description":"LauncherWaitTimeout caps how long mcpproxy will wait for a locally-launched\nHTTP/SSE upstream's URL to become reachable after Spawn(). Only consulted\nwhen the server is configured with both Command and an HTTP/SSE URL — i.e.,\nmcpproxy starts the process AND connects via network. Stdio servers ignore\nthis field. Zero or unset → 30s default.","type":"string"},"max_concurrent_requests":{"description":"Per-server concurrency overrides — scope (c) of FR-020 (spec 093, #955).\nTri-state per setting, exactly like HealthCheckInterval: absent = inherit\nthe per-server default set (server_concurrency_defaults), explicit 0 =\ndisable that setting for this server (0 max = no per-server limiter at\nall; 0 queue_size = no pending capacity, shed immediately at the cap),\npositive = override. The global aggregate limiter is never inherited from\nhere — it applies on top, so effective concurrency is min(per-server,\nglobal). Resolved by Config.ResolveServerConcurrency.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/config.OAuthConfig"},"protocol":{"description":"stdio, http, sse, streamable-http, auto","type":"string"},"quarantined":{"description":"Security quarantine status","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets a disconnected server","type":"boolean"},"shared":{"description":"Server edition: shared with all users","type":"boolean"},"skip_quarantine":{"description":"SkipQuarantine is DEPRECATED (MCP-2930): use AutoApproveToolChanges instead.\nKept for back-compat parsing; on config load a legacy skip_quarantine:true is\nmigrated to auto_approve_tool_changes:true only when the new field is unset\n(see normalizeServerQuarantineFlags).","type":"boolean"},"source_registry_id":{"description":"SourceRegistryID records which registry this server was added from (empty\nfor manually-configured servers). MCP-866: surfaced in the approval /\nquarantine view so a reviewer can see a server's origin.","type":"string"},"source_registry_provenance":{"description":"SourceRegistryProvenance records the source registry's provenance at add\ntime (RegistryProvenanceOfficial / RegistryProvenanceCustom). It is purely\ninformational (MCP-1072) — surfaced so a reviewer can see a server's origin\n— and no longer gates quarantine or skip_quarantine.","type":"string"},"tool_discovery_interval":{"type":"string"},"toon_output":{"description":"ToonOutput overrides the global toon_output mode for this server's\ntools (spec 084, FR-001). Plain string, not a pointer: \"\"/absent =\ninherit the global value; \"off\"|\"adaptive\"|\"always\" = override (\"off\"\nis the explicit force-off). Resolved by Config.ResolveToonOutput.","type":"string"},"trust_mode":{"description":"TrustMode is the per-server trust tier: auto|scan|manual. Supersedes\nauto_approve_tool_changes (spec 086). An empty value is derived from the\nlegacy fields at load via normalizeServerQuarantineFlags; the single\nresolution point is EffectiveTrustMode(), which treats an empty or\nunrecognized value as manual (secure by default). Read via\nEffectiveTrustMode(), never the raw string.","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"working_dir":{"description":"Working directory for stdio servers","type":"string"}},"type":"object"},"config.TLSConfig":{"description":"TLS configuration","properties":{"certs_dir":{"description":"Directory for certificates","type":"string"},"enabled":{"description":"Enable HTTPS","type":"boolean"},"hsts":{"description":"Enable HTTP Strict Transport Security","type":"boolean"},"require_client_cert":{"description":"Enable mTLS","type":"boolean"}},"type":"object"},"config.TelemetryConfig":{"description":"Telemetry settings (Spec 036)","properties":{"anonymous_id":{"description":"Auto-generated UUIDv4","type":"string"},"anonymous_id_created_at":{"description":"Spec 042 (Tier 2) additions — all default-zero, all backwards-compatible.","type":"string"},"enabled":{"description":"Default: true (opt-out)","type":"boolean"},"endpoint":{"description":"Override for testing","type":"string"},"last_reported_version":{"description":"Upgrade funnel","type":"string"},"last_startup_outcome":{"description":"success|port_conflict|db_locked|...","type":"string"},"notice_shown":{"description":"First-run notice flag","type":"boolean"}},"type":"object"},"config.TokenizerConfig":{"description":"Tokenizer configuration for token counting","properties":{"default_model":{"description":"Default model for tokenization (e.g., \"gpt-4\")","type":"string"},"enabled":{"description":"Enable token counting","type":"boolean"},"encoding":{"description":"Default encoding (e.g., \"cl100k_base\")","type":"string"}},"type":"object"},"config.TracingExporterConfig":{"description":"Tracing gates the OpenTelemetry OTLP trace exporter (MCP-32). Disabled by\ndefault.","properties":{"enabled":{"description":"Enabled turns on OTLP trace export for tool calls and upstream hops.","type":"boolean"},"endpoint":{"description":"Endpoint is the collector address as host:port (no scheme), e.g.\n\"localhost:4318\" for http or \"localhost:4317\" for grpc.","type":"string"},"protocol":{"description":"Protocol selects the OTLP transport: \"http\" or \"grpc\".","type":"string"},"sample_rate":{"description":"SampleRate is the head-based trace sampling ratio in [0,1]. Default 0.1.\nOmit the key for the 0.1 default; set it to 0 to sample nothing.","type":"number"}},"type":"object"},"config.UpdateCheckConfig":{"description":"Update-check settings (Spec 079 FR-012): config-file control of the\nbackground upgrade-awareness checker (internal/updatecheck). nil =\nenabled on the stable channel (existing default behavior). The existing\nenvironment switches keep working and WIN over these keys (FR-014):\nMCPPROXY_DISABLE_AUTO_UPDATE=true force-disables even when\nenabled=true, and MCPPROXY_ALLOW_PRERELEASE_UPDATES=true force-selects\nthe rc channel even when channel=stable.","properties":{"channel":{"description":"Channel selects which releases are offered as updates: \"stable\"\n(default; prereleases never offered) or \"rc\" (prereleases included).\nEmpty resolves to stable. Validated in ValidateDetailed.\n\nNOTE: for a RELEASED build the running binary's own version is\nauthoritative and overrides this field — a stable build is never\noffered an RC (even with channel=rc), and an RC build always tracks the\nrc channel. This field only takes effect on dev/unstamped builds. See\ninternal/updatecheck.Checker.IncludePrereleases.","type":"string"},"enabled":{"description":"Enabled gates all update checking. Tri-state: nil/absent = enabled\n(default true, matching pre-079 behavior). When false, no network\ncheck is performed and no upgrade nudge appears on any surface\n(FR-015) — /api/v1/info omits the update object entirely.","type":"boolean"}},"type":"object"},"configimport.FailedServer":{"properties":{"details":{"type":"string"},"error":{"type":"string"},"name":{"type":"string"}},"type":"object"},"configimport.ImportSummary":{"properties":{"failed":{"type":"integer"},"imported":{"type":"integer"},"skipped":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"configimport.SkippedServer":{"properties":{"name":{"type":"string"},"reason":{"description":"\"already_exists\", \"filtered_out\", \"invalid_name\", \"self_reference\"","type":"string"}},"type":"object"},"connect.ConnectResult":{"description":"The full result; its action mirrors the top-level one","properties":{"action":{"description":"\"created\", \"updated\", \"already_exists\", \"removed\", \"not_found\"","type":"string"},"backup_path":{"type":"string"},"client":{"type":"string"},"config_path":{"type":"string"},"credential":{"description":"Credential is the masked client credential the write embedded\n(` + "`" + `mcp_cli_••••` + "`" + `); the real secret is never returned (FR-024). Empty for a\nkeyless entry, a disconnect and every refusal.","type":"string"},"credential_revoke_error":{"description":"CredentialRevokeError is set when a disconnect removed the entry but\nrevoking the client credential failed. The disconnect still succeeded;\nthe credential is still active and ` + "`" + `mcpproxy client forget \u003cid\u003e` + "`" + `\nretries the revoke.","type":"string"},"credential_revoked":{"description":"CredentialRevoked names the credential that was revoked: by an undo,\nbecause the restored config no longer holds it (plan D16), or by a\ndisconnect, which cuts the client off (a later undo of that disconnect\ndoes not bring the credential back).","type":"string"},"display_path":{"description":"DisplayPath is ConfigPath with the home directory shortened to \"~\"\n(FR-037). Populated for every result whose ConfigPath is known.","type":"string"},"keyless":{"description":"Keyless is true when the entry was written with no credential.","type":"boolean"},"message":{"type":"string"},"mode":{"type":"string"},"profile":{"description":"Profile and Mode are the binding of the credential this write minted or\nkept. Profile \"\" means the built-in All servers scope.","type":"string"},"reload_hint":{"description":"ReloadHint is this client's instruction for making the write take\neffect (FR-037/FR-042), e.g. \"Restart Cursor to load MCPProxy\". Empty\nfor an unknown client.","type":"string"},"rotation":{"description":"Rotation is \"finalized\" when the write replaced an active credential's\nsecret (staged rotation, FR-021a), empty otherwise.","type":"string"},"server_name":{"type":"string"},"success":{"type":"boolean"},"token_name":{"description":"TokenName is the client credential's token name (` + "`" + `client-\u003cid\u003e` + "`" + `).","type":"string"}},"type":"object"},"contracts.APIResponse":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ActivityDetailResponse":{"properties":{"activity":{"$ref":"#/components/schemas/contracts.ActivityRecord"}},"type":"object"},"contracts.ActivityListResponse":{"properties":{"activities":{"items":{"$ref":"#/components/schemas/contracts.ActivityRecord"},"type":"array","uniqueItems":false},"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.ActivityPerServer":{"properties":{"calls":{"description":"Calls counted per storage.CountsAsCall","type":"integer"},"errors":{"description":"Of those calls, how many failed","type":"integer"},"last_call_at":{"description":"LastCallAt is RFC3339, or \"\" if the server had no call in the period\n(PerServer only lists servers that did, so this is always set).","type":"string"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityRecord":{"properties":{"agent_name":{"description":"Agent token name when auth_type is \"agent\"","type":"string"},"arguments":{"description":"Tool call arguments","type":"object"},"auth_type":{"description":"\"admin\", \"agent\", \"user\" or \"admin_user\"; empty without an auth context or on another caller's row for a scoped caller","type":"string"},"block_reason":{"description":"Typed cause of a profile policy refusal","type":"string"},"client_id":{"description":"Client id from the client-credential binding","type":"string"},"client_name":{"description":"Self-reported clientInfo.name (advisory)","type":"string"},"detection_types":{"description":"List of detection types found","items":{"type":"string"},"type":"array","uniqueItems":false},"duration_ms":{"description":"Execution duration in milliseconds","type":"integer"},"error_class":{"description":"Call-error taxonomy (Spec 113-c FR-045): what failed and whose fault it\nis. Absent on success and on records written before Spec 113.","type":"string"},"error_message":{"description":"Error details if status is \"error\"","type":"string"},"fault_domain":{"description":"upstream|proxy|client","type":"string"},"has_sensitive_data":{"description":"Sensitive data detection fields (Spec 026)","type":"boolean"},"id":{"description":"Unique identifier (ULID format)","type":"string"},"max_severity":{"description":"Highest severity level detected (critical, high, medium, low)","type":"string"},"metadata":{"description":"Additional context-specific data","type":"object"},"parent_id":{"description":"Correlation id of the parent call (the code_execution whose sandbox issued this sub-call)","type":"string"},"profile":{"description":"Scope attribution (Spec 108 FR-029): the profile, client and token in\neffect when the call ran. Stamped at write time, never rewritten. For a\nscoped (non-admin) caller the profile/profile_source/client_id/token_name\nof a row it did not make are blanked (binding disclosure is admin-only);\nclient_name is self-reported and stays. Absent on records written before\nSpec 108 and on records with no MCP/REST request context.","type":"string"},"profile_source":{"description":"pin|binding|url|session|anonymous|none","type":"string"},"request_bytes":{"description":"Byte sizes measured pre-truncation, mirroring storage.ActivityRecord\n(Spec 069 A1). They are the only cost signal a bodies-off export carries:\nwith payloads suppressed there is no text left to measure, so a consumer\naccounting for a record it cannot read has nothing else to go on. They are\nbyte LENGTHS, not token counts — the basis for an explicit estimate, never\na measured figure (spec 103, contracts/replay-input.md).\n\nZero means UNKNOWN, not free: legacy records predate the measurement and\ncode-execution sub-calls record both as zero. Hence omitempty — an absent\nkey tells a consumer to fall to exclusion accounting, whereas a present\nzero would read as a costless call and silently understate the workload.","type":"integer"},"request_id":{"description":"HTTP request ID for correlation","type":"string"},"response":{"description":"Tool response (potentially truncated)","type":"string"},"response_bytes":{"description":"Raw upstream response size in bytes before truncation","type":"integer"},"response_truncated":{"description":"True if response was truncated","type":"boolean"},"server_name":{"description":"Name of upstream MCP server","type":"string"},"session_id":{"description":"MCP transport session ID (regenerated on every reconnect)","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\", \"rejected\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"token_name":{"description":"Agent/client token name","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"},"upstream_http_status":{"description":"Upstream HTTP status when known","type":"integer"},"work_session_id":{"description":"Spec 082: one client, one project, across reconnects","type":"string"}},"type":"object"},"contracts.ActivitySource":{"description":"How activity was triggered: \"mcp\", \"cli\", \"api\"","type":"string","x-enum-varnames":["ActivitySourceMCP","ActivitySourceCLI","ActivitySourceAPI"]},"contracts.ActivitySummaryResponse":{"properties":{"blocked_count":{"description":"Count of blocked activities","type":"integer"},"call_count":{"description":"CallCount is how many of those records are CALLS THE USER MADE, as\ndefined once in storage.CountsAsCall and shared with the usage aggregate\nbehind the Usage tab (audit finding F1, #1046). TotalCount answers \"how\nmany rows does the Activity Log have\"; CallCount answers \"how many calls\nwere there\". They are different questions — quarantine auto-approvals,\nsystem start, security scans and management chatter are events, not calls\n— and printing either one under the other's label is how the same instance\ncame to report 51 calls on one screen and 19 on another.","type":"integer"},"call_error_count":{"description":"CallErrorCount is the failures within CallCount, so an error RATE computed\nfrom this response has one denominator. It is not ErrorCount: a policy\nblock is a failed call but carries status \"blocked\", and a shed call is an\nerror in neither sense because it never ran.","type":"integer"},"end_time":{"description":"End of the period (RFC3339)","type":"string"},"error_count":{"description":"Count of error activities","type":"integer"},"other_count":{"description":"OtherCount is every record whose status is outside the four-value\nvocabulary above, so that\n\n\tsuccess + error + blocked + rejected + other == total\n\nholds by construction. The status field is a CLOSED vocabulary for tool\ncalls, but the activity log is wider than tool calls: a quarantine change\nstores its ACTION there (\"approved\", \"auto_approved\"), a policy decision\nstores its DECISION (\"allow\"). Those rows were counted in the total and in\nnone of the four buckets, so the Activity Log's own status tiles summed to\nless than the denominator printed beside them — 15+4+0+0 under a \"42\"\n(audit finding F2, #1046). The residual now has a name and a tile.","type":"integer"},"per_server":{"description":"PerServer covers EVERY server with at least one call in the period\n(unlike TopServers, which is capped at 5 and carries no error counts).\nSpec 109 FR-013: the server-card stats line and the macOS Servers rows\nread this — one ` + "`" + `GET /activity/summary` + "`" + ` response per page load — rather\nthan issuing a per-server activity query each. Computed in the same\ncounting pass as the totals above, from the same CountsAsCall/\nIsManagementBuiltin definitions TopServers already uses.","items":{"$ref":"#/components/schemas/contracts.ActivityPerServer"},"type":"array","uniqueItems":false},"period":{"description":"Time period (1h, 24h, 7d, 30d)","type":"string"},"rejected_count":{"description":"RejectedCount is the number of calls shed by a concurrency limiter before\nthey reached an upstream (spec 093). Counted separately from errors: it is\nproxy backpressure, not an upstream fault, and it is the signal an\noperator right-sizes max_concurrent_requests against.","type":"integer"},"start_time":{"description":"Start of the period (RFC3339)","type":"string"},"success_count":{"description":"Count of successful activities","type":"integer"},"top_servers":{"description":"Top servers by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopServer"},"type":"array","uniqueItems":false},"top_tools":{"description":"Top tools by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopTool"},"type":"array","uniqueItems":false},"total_count":{"description":"Total activity count","type":"integer"}},"type":"object"},"contracts.ActivityTopServer":{"properties":{"count":{"description":"Activity count","type":"integer"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityTopTool":{"properties":{"count":{"description":"Activity count","type":"integer"},"server":{"description":"Server name","type":"string"},"tool":{"description":"Tool name","type":"string"}},"type":"object"},"contracts.ActivityType":{"description":"Type of activity","type":"string","x-enum-varnames":["ActivityTypeToolCall","ActivityTypePolicyDecision","ActivityTypeQuarantineChange","ActivityTypeServerChange"]},"contracts.AddFromRegistryRequest":{"properties":{"enabled":{"description":"defaults to true when nil","type":"boolean"},"env":{"additionalProperties":{"type":"string"},"description":"overrides + required-input values","type":"object"},"name":{"description":"optional name override","type":"string"}},"type":"object"},"contracts.AddRegistrySourceRequest":{"properties":{"id":{"description":"derived from the host when empty","type":"string"},"name":{"description":"defaults to the id","type":"string"},"protocol":{"description":"defaults to modelcontextprotocol/registry","type":"string"},"url":{"description":"required https registry URL","type":"string"}},"type":"object"},"contracts.AttentionFix":{"properties":{"label":{"type":"string"},"target":{"type":"string"},"verb":{"type":"string"}},"type":"object"},"contracts.AttentionItem":{"properties":{"detail":{"type":"string"},"fix":{"$ref":"#/components/schemas/contracts.AttentionFix"},"id":{"description":"kind:type:subject[:state]","type":"string"},"kind":{"type":"string"},"rank":{"type":"integer"},"since":{"type":"string"},"subject":{"$ref":"#/components/schemas/contracts.AttentionSubject"},"summary":{"type":"string"}},"type":"object"},"contracts.AttentionSubject":{"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"description":"server|tool|client|setting","type":"string"}},"type":"object"},"contracts.ConfigApplyResult":{"properties":{"applied_immediately":{"type":"boolean"},"changed_fields":{"items":{"type":"string"},"type":"array","uniqueItems":false},"requires_restart":{"type":"boolean"},"restart_reason":{"type":"string"},"success":{"type":"boolean"},"validation_errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DCRStatus":{"properties":{"attempted":{"type":"boolean"},"error":{"type":"string"},"status_code":{"type":"integer"},"success":{"type":"boolean"}},"type":"object"},"contracts.DeepScanDescriptor":{"description":"DeepScan reports the opt-in \"deep scan\" layer status (Spec 077 US3),\nSEPARATELY from the baseline verdict above. Always emitted on a computed\nsummary — when deep scan is off (the default) it reports enabled=false\nplus any enabled-but-skipped Docker scanners. It never influences Status.","properties":{"available":{"type":"boolean"},"enabled":{"type":"boolean"},"ran":{"type":"boolean"},"scanners_failed":{"items":{"$ref":"#/components/schemas/contracts.DeepScanScannerFailure"},"type":"array","uniqueItems":false},"skipped_scanners":{"description":"SkippedScanners lists Docker scanners the user enabled that are skipped\nbecause security.deep_scan.enabled is false (informational).","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DeepScanScannerFailure":{"properties":{"id":{"type":"string"},"reason":{"type":"string"}},"type":"object"},"contracts.DeprecatedConfigWarning":{"properties":{"field":{"type":"string"},"message":{"type":"string"},"replacement":{"type":"string"}},"type":"object"},"contracts.Diagnostic":{"description":"Spec 044 — structured diagnostic error and stable error code. Both\nare populated when the server is in a failed state and the error\nhas been classified by internal/diagnostics. Healthy servers omit\nthese fields.","properties":{"cause":{"type":"string"},"code":{"type":"string"},"detected_at":{"type":"string"},"docs_url":{"type":"string"},"fix_steps":{"items":{"$ref":"#/components/schemas/contracts.DiagnosticFixStep"},"type":"array","uniqueItems":false},"severity":{"type":"string"},"user_message":{"type":"string"}},"type":"object"},"contracts.DiagnosticFixStep":{"properties":{"command":{"type":"string"},"destructive":{"type":"boolean"},"fixer_key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"},"url":{"type":"string"}},"type":"object"},"contracts.Diagnostics":{"properties":{"deprecated_configs":{"description":"Deprecated config fields found","items":{"$ref":"#/components/schemas/contracts.DeprecatedConfigWarning"},"type":"array","uniqueItems":false},"docker_status":{"$ref":"#/components/schemas/contracts.DockerStatus"},"missing_secrets":{"description":"Renamed to avoid conflict","items":{"$ref":"#/components/schemas/contracts.MissingSecretInfo"},"type":"array","uniqueItems":false},"oauth_issues":{"description":"OAuth parameter mismatches","items":{"$ref":"#/components/schemas/contracts.OAuthIssue"},"type":"array","uniqueItems":false},"oauth_required":{"items":{"$ref":"#/components/schemas/contracts.OAuthRequirement"},"type":"array","uniqueItems":false},"runtime_warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false},"timestamp":{"type":"string"},"total_issues":{"type":"integer"},"upstream_errors":{"items":{"$ref":"#/components/schemas/contracts.UpstreamError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DockerStatus":{"properties":{"available":{"type":"boolean"},"error":{"type":"string"},"version":{"type":"string"}},"type":"object"},"contracts.EditRegistrySourceRequest":{"properties":{"name":{"description":"new display name","type":"string"},"servers_url":{"description":"explicit servers-collection URL","type":"string"},"url":{"description":"new base/servers https URL","type":"string"}},"type":"object"},"contracts.ErrorResponse":{"properties":{"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.FindingCounts":{"properties":{"dangerous":{"description":"Tool poisoning, active prompt injection","type":"integer"},"info":{"description":"Low-severity CVEs, informational","type":"integer"},"total":{"type":"integer"},"warning":{"description":"Rug pull, supply chain CVEs with exploits","type":"integer"}},"type":"object"},"contracts.GetConfigResponse":{"properties":{"config":{"description":"The configuration object","type":"object"},"config_path":{"description":"Path to config file","type":"string"}},"type":"object"},"contracts.GetRegistriesResponse":{"properties":{"registries":{"items":{"$ref":"#/components/schemas/contracts.Registry"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerLogsResponse":{"properties":{"count":{"type":"integer"},"logs":{"items":{"$ref":"#/components/schemas/contracts.LogEntry"},"type":"array","uniqueItems":false},"server_name":{"type":"string"}},"type":"object"},"contracts.GetServerToolCallsResponse":{"properties":{"server_name":{"type":"string"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerToolsResponse":{"properties":{"count":{"type":"integer"},"incarnation":{"description":"Incarnation is the committed incarnation of the server the tools were\nread for (Spec 116 FR-017), captured before they were fetched; omitted\nwhen unknown.","type":"integer"},"server_name":{"type":"string"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GetServersResponse":{"properties":{"servers":{"items":{"$ref":"#/components/schemas/contracts.Server"},"type":"array","uniqueItems":false},"stats":{"$ref":"#/components/schemas/contracts.ServerStats"}},"type":"object"},"contracts.GetSessionDetailResponse":{"properties":{"session":{"$ref":"#/components/schemas/contracts.MCPSession"}},"type":"object"},"contracts.GetSessionsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"sessions":{"items":{"$ref":"#/components/schemas/contracts.MCPSession"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetToolCallDetailResponse":{"properties":{"tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"}},"type":"object"},"contracts.GetToolCallsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GlobalToolsResponse":{"properties":{"counts":{"$ref":"#/components/schemas/contracts.ViewAsCounts"},"failed_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"partial":{"type":"boolean"},"stats":{"$ref":"#/components/schemas/contracts.GlobalToolsStats"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GlobalToolsStats":{"properties":{"disabled":{"type":"integer"},"enabled":{"type":"integer"},"pending_approval":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.HealthStatus":{"description":"Unified health status calculated by the backend","properties":{"action":{"description":"Action is the suggested fix action: \"login\", \"restart\", \"enable\", \"approve\", \"view_logs\", \"set_secret\", \"configure\", \"edit_url\", or \"\" (none)\nInvariant: Action always equals Actions[0], or \"\" when Actions is empty.","type":"string"},"actions":{"description":"Actions lists every applicable next step in priority order (FR-012):\nlogin \u003e set_secret \u003e configure \u003e edit_url \u003e approve \u003e restart \u003e\nview_logs \u003e enable. Always non-nil (empty slice, never null).","items":{"type":"string"},"type":"array","uniqueItems":false},"admin_state":{"description":"AdminState indicates the admin state: \"enabled\", \"disabled\", or \"quarantined\"","type":"string"},"detail":{"description":"Detail is an optional longer explanation of the status","type":"string"},"level":{"description":"Level indicates the health level: \"healthy\", \"degraded\", or \"unhealthy\"","type":"string"},"status":{"description":"Status is the ONE status vocabulary rendered as text on every surface\n(Web UI, macOS, tray, CLI) — Spec 109 FR-010/FR-011. Values: \"ready\",\n\"connecting\", \"sign_in_required\", \"needs_review\", \"needs_secret\",\n\"needs_config\", \"error\", \"disabled\". Unlike Level (a severity signal for\nbadge/tray coloring only), no renderer may print Level as text.","type":"string"},"summary":{"description":"Summary is a human-readable status message (e.g., \"Connected (5 tools)\")","type":"string"},"usable":{"description":"Usable reports whether the server can currently serve tool calls. True\nonly when Status == \"ready\".","type":"boolean"}},"type":"object"},"contracts.InfoEndpoints":{"description":"Available API endpoints","properties":{"http":{"description":"HTTP endpoint address (e.g., \"127.0.0.1:8080\")","type":"string"},"socket":{"description":"Unix socket path (empty if disabled)","type":"string"}},"type":"object"},"contracts.InfoResponse":{"properties":{"endpoints":{"$ref":"#/components/schemas/contracts.InfoEndpoints"},"launched_by":{"description":"LaunchedBy is the durable launch provenance of the running core (Spec\n092 FR-001a): \"tray\" when a tray spawned it, \"installer\" when the macOS\nPKG postinstall did, \"\" when user-launched or unknown. Always present\n(possibly empty) so a tray can distinguish \"old core, not mine\" from\n\"old core I may supersede\".","type":"string"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"pid":{"description":"PID is the operating-system process id of the running core (Spec 092\nFR-002). A tray that merely ATTACHED to a core holds no Process handle\nfor it, so without this there is no mechanism at all to stop a stale\ncore — the consent action would have nothing to act on and could only\nprint instructions. Paired with LaunchedBy it is what lets a newer tray\nsupersede a core an older tray started.","type":"integer"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"update_policy":{"$ref":"#/components/schemas/contracts.UpdatePolicy"},"version":{"description":"Current MCPProxy version","type":"string"},"web_ui_url":{"description":"URL to access the web control panel","type":"string"}},"type":"object"},"contracts.IsolationConfig":{"properties":{"cpu_limit":{"type":"string"},"enabled":{"description":"Enabled is the EFFECTIVE isolation state for this server: whether its\nprocess is actually CONFINED, after the global setting, the per-server\noverride, the structural gates and the host's capabilities. It is NOT the\nraw per-server override — read EnabledOverride for that (GH #1142).\n\nREAD-ONLY. The write surfaces reject an ` + "`" + `enabled` + "`" + ` key precisely because\nit is derived: echoing it back would convert \"inherits the global\nsetting\" into a permanent explicit override. Write EnabledOverride.\n\nIt stays a non-pointer bool that is always present on the wire: the macOS\ntray decodes it as a non-optional Swift Bool, so omitting or nulling the\nkey would fail Codable for the whole server payload. Older clients that\nread this field now simply get a true answer.","type":"boolean"},"enabled_override":{"description":"EnabledOverride is the RAW per-server ` + "`" + `isolation.enabled` + "`" + ` override, as\npersisted. Absent means \"inherit the global setting\" — which is a\ndistinct state from an explicit false, and the distinction the reporting\nbug used to destroy.","type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"memory_limit":{"type":"string"},"mode_override":{"description":"ModeOverride is the RAW per-server ` + "`" + `isolation.mode` + "`" + ` override\n(\"docker\" | \"sandbox\" | \"none\"). Absent means \"inherit\".","type":"string"},"network_mode":{"type":"string"},"timeout":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.IsolationDefaults":{"description":"IsolationDefaults exposes the resolved baseline values that\nwould apply when no per-server override is set. Populated on\nlist/get responses; never consumed on PATCH requests.","properties":{"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"runtime_type":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.IsolationEffective":{"description":"IsolationEffective exposes the resolved isolation state (and the rule\nthat decided it) so clients can distinguish \"inherits global\" from an\nexplicit per-server choice. Read-only; never consumed on PATCH.","properties":{"global_mode":{"description":"GlobalMode is what \"inherit\" resolves to right now.","type":"string"},"inherited":{"description":"Inherited is true when the server sets neither ` + "`" + `isolation.enabled` + "`" + ` nor\n` + "`" + `isolation.mode` + "`" + `, so its state tracks the global setting.","type":"boolean"},"isolated":{"description":"Isolated reports whether the process is actually CONFINED. It is NOT\nsimply Mode != \"none\": \"sandbox\" on a host that cannot enforce Landlock\n(any non-Linux OS, or a kernel without the LSM) runs the server\nunconfined, and Source then says \"sandbox-unavailable\" (GH #1142).","type":"boolean"},"mode":{"description":"Mode is the effective isolation mode: \"docker\" | \"sandbox\" | \"none\" —\nexactly what the spawn path branches on.","type":"string"},"source":{"description":"Source names the deciding rule: \"global\", \"server-mode\",\n\"server-opt-out\", \"server-opt-in-ignored\", \"not-stdio\",\n\"already-docker\", \"sandbox-unavailable\" or \"unsupported-mode\".\nTreat an unrecognized value as \"global\".","type":"string"}},"type":"object"},"contracts.LogEntry":{"properties":{"fields":{"type":"object"},"level":{"type":"string"},"message":{"type":"string"},"server":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.MCPSession":{"properties":{"client_id":{"description":"Scope attribution (Spec 108 FR-033). ClientID and TokenName are the\ncredential the session initialized with; Profile and ProfileSource are\nthe session's latest effective resolution. Empty on legacy sessions.","type":"string"},"client_name":{"type":"string"},"client_version":{"type":"string"},"end_time":{"type":"string"},"experimental":{"items":{"type":"string"},"type":"array","uniqueItems":false},"has_roots":{"description":"MCP Client Capabilities","type":"boolean"},"has_sampling":{"type":"boolean"},"id":{"type":"string"},"last_activity":{"type":"string"},"profile":{"type":"string"},"profile_source":{"type":"string"},"start_time":{"type":"string"},"status":{"type":"string"},"token_name":{"type":"string"},"tool_call_count":{"type":"integer"},"total_tokens":{"type":"integer"},"work_session_id":{"type":"string"},"workspace_name":{"description":"Workspace / work session (Spec 082). WorkspaceName is the project's\nbasename — the full local path is never exposed. WorkSessionID groups the\nreconnects that make up one stretch of user work.","type":"string"}},"type":"object"},"contracts.MetadataStatus":{"properties":{"authorization_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"error":{"type":"string"},"found":{"type":"boolean"},"url_checked":{"type":"string"}},"type":"object"},"contracts.MissingSecretInfo":{"properties":{"secret_name":{"type":"string"},"used_by":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.NPMPackageInfo":{"properties":{"exists":{"type":"boolean"},"install_cmd":{"type":"string"}},"type":"object"},"contracts.OAuthConfig":{"properties":{"auth_server_metadata_url":{"type":"string"},"auth_url":{"type":"string"},"authorization_endpoint":{"description":"Spec 113-b operator overrides of OAuth discovery (https-or-loopback URLs,\nnever credentials). Empty means \"use discovery\".","type":"string"},"client_id":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_port":{"type":"integer"},"registration_endpoint":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"token_endpoint":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires","type":"string"},"token_url":{"type":"string"},"token_valid":{"description":"Whether token is currently valid","type":"boolean"}},"type":"object"},"contracts.OAuthErrorDetails":{"description":"Structured discovery/failure details","properties":{"authorization_server_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"dcr_status":{"$ref":"#/components/schemas/contracts.DCRStatus"},"protected_resource_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"server_url":{"type":"string"}},"type":"object"},"contracts.OAuthFlowError":{"properties":{"correlation_id":{"description":"Flow tracking ID for log correlation","type":"string"},"debug_hint":{"description":"CLI command for log lookup","type":"string"},"details":{"$ref":"#/components/schemas/contracts.OAuthErrorDetails"},"error_code":{"description":"Machine-readable error code (e.g., OAUTH_NO_METADATA)","type":"string"},"error_type":{"description":"Category of OAuth runtime failure","type":"string"},"message":{"description":"Human-readable error description","type":"string"},"request_id":{"description":"HTTP request ID (from PR #237)","type":"string"},"server_name":{"description":"Server that failed OAuth","type":"string"},"success":{"description":"Always false","type":"boolean"},"suggestion":{"description":"Actionable remediation hint","type":"string"}},"type":"object"},"contracts.OAuthIssue":{"properties":{"documentation_url":{"type":"string"},"error":{"type":"string"},"issue":{"type":"string"},"missing_params":{"items":{"type":"string"},"type":"array","uniqueItems":false},"resolution":{"type":"string"},"server_name":{"type":"string"}},"type":"object"},"contracts.OAuthRequirement":{"properties":{"expires_at":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"state":{"type":"string"}},"type":"object"},"contracts.OAuthStartResponse":{"properties":{"auth_url":{"description":"Authorization URL (always included for manual use)","type":"string"},"browser_error":{"description":"Error message if browser launch failed","type":"string"},"browser_opened":{"description":"Whether browser launch succeeded","type":"boolean"},"correlation_id":{"description":"UUID for tracking this flow","type":"string"},"message":{"description":"Human-readable status message","type":"string"},"server_name":{"description":"Name of the server being authenticated","type":"string"},"success":{"description":"Always true for successful start","type":"boolean"}},"type":"object"},"contracts.PreflightPolicy":{"properties":{"exclude_destructive":{"type":"boolean"},"exclude_open_world":{"type":"boolean"},"read_only_only":{"type":"boolean"}},"type":"object"},"contracts.PreflightReason":{"type":"string","x-enum-varnames":["PreflightReasonServerInitializing","PreflightReasonServerUnhealthy","PreflightReasonServerDisabled","PreflightReasonServerQuarantined","PreflightReasonToolPendingApproval","PreflightReasonToolChanged","PreflightReasonToolBlockedByUser","PreflightReasonOAuthRequired","PreflightReasonHashMismatch","PreflightReasonServerNotInScope","PreflightReasonToolDeniedByConfig","PreflightReasonMissingAnnotation","PreflightReasonPolicyFiltered","PreflightReasonNotFound","PreflightReasonServerNotConfigured","PreflightReasonToolBlockedByProfile","PreflightReasonTokenPermissionDenied"]},"contracts.PreflightRequest":{"properties":{"policy":{"$ref":"#/components/schemas/contracts.PreflightPolicy"},"profile":{"description":"Profile evaluates under a named profile's server scope and tool policy;\nit can only narrow a credential's own pin. Unknown: 400.","type":"string"},"tools":{"description":"Tools is 1..100 entries BEFORE dedup; duplicates are collapsed, and\nduplicate ids carrying different pins are a validation error.","items":{"$ref":"#/components/schemas/contracts.PreflightToolRef"},"type":"array","uniqueItems":false},"wait_ms":{"description":"WaitMS polls local state for up to this many milliseconds (cap 10000)\nwhile every failure is retryable-class.","type":"integer"}},"type":"object"},"contracts.PreflightResponse":{"properties":{"checked_at":{"type":"string"},"tools":{"description":"Tools are ordered by first occurrence of each unique id in the request.","items":{"$ref":"#/components/schemas/contracts.PreflightToolResult"},"type":"array","uniqueItems":false},"verdict":{"$ref":"#/components/schemas/contracts.PreflightVerdict"},"waited_ms":{"description":"WaitedMS is present when wait_ms was requested (0 when the wait\nsemaphore was exhausted and the request resolved immediately).","type":"integer"}},"type":"object"},"contracts.PreflightStatus":{"type":"string","x-enum-varnames":["PreflightStatusReady","PreflightStatusUnavailable"]},"contracts.PreflightToolRef":{"properties":{"id":{"description":"ID is a canonical \"\u003cserver\u003e:\u003ctool\u003e\" id. A malformed id is answered with a\nper-ID not_found carrying a format hint, never a request-level error.","type":"string"},"pin_hash":{"description":"PinHash is \"sha256/v{N}:{hex}\" — the schema version is embedded so a\nproxy-side hash-algorithm bump is distinguishable from upstream drift.","type":"string"}},"type":"object"},"contracts.PreflightToolResult":{"properties":{"action":{"type":"string"},"detail":{"type":"string"},"did_you_mean":{"description":"DidYouMean carries up to 3 nearest caller-visible ids on not_found. It\nnever crosses a scope boundary and never names a quarantined server's\ntools.","items":{"type":"string"},"type":"array","uniqueItems":false},"hash":{"description":"Hash is the tool's current pin (\"sha256/v{N}:{hex}\") — operator tier,\nready results only. Never disclosed to an agent token.","type":"string"},"id":{"type":"string"},"reason":{"$ref":"#/components/schemas/contracts.PreflightReason"},"remediation":{"type":"string"},"retryable":{"type":"boolean"},"status":{"$ref":"#/components/schemas/contracts.PreflightStatus"}},"type":"object"},"contracts.PreflightVerdict":{"type":"string","x-enum-varnames":["PreflightVerdictReady","PreflightVerdictDegradedRetryable","PreflightVerdictBlocked","PreflightVerdictUnknownIDs"]},"contracts.QuarantineStats":{"description":"Tool quarantine metrics for this server","properties":{"blocked_count":{"description":"Number of disabled (blocked) tools","type":"integer"},"changed_count":{"description":"Number of tools whose description/schema changed since approval","type":"integer"},"pending_count":{"description":"Number of newly discovered tools awaiting approval","type":"integer"}},"type":"object"},"contracts.RefreshRegistryResponse":{"properties":{"cleared":{"description":"number of cached entries dropped","type":"integer"},"registry_id":{"type":"string"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag (MCP-866): \"official/trusted\" for built-in\ndefaults, \"custom/unverified\" for user-added registries.","type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"trusted":{"description":"Trusted indicates whether this is an official, shipped-by-default\nregistry. Trust is derived from membership in the default set, never\nfrom self-assertion in config.","type":"boolean"},"url":{"type":"string"}},"type":"object"},"contracts.RegistryCacheInfo":{"properties":{"age_seconds":{"type":"number"},"stale":{"type":"boolean"}},"type":"object"},"contracts.RegistryUnavailable":{"properties":{"reason":{"type":"string"}},"type":"object"},"contracts.ReplayToolCallRequest":{"properties":{"arguments":{"description":"Modified arguments for replay","type":"object"}},"type":"object"},"contracts.ReplayToolCallResponse":{"properties":{"error":{"description":"Error if replay failed","type":"string"},"new_call_id":{"description":"ID of the newly created call","type":"string"},"new_tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"replayed_from":{"description":"Original call ID","type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.RepositoryInfo":{"description":"Detected package info","properties":{"npm":{"$ref":"#/components/schemas/contracts.NPMPackageInfo"}},"type":"object"},"contracts.RepositoryServer":{"properties":{"connect_url":{"description":"Alternative connection URL","type":"string"},"created_at":{"type":"string"},"description":{"type":"string"},"id":{"type":"string"},"install_cmd":{"description":"Installation command","type":"string"},"name":{"type":"string"},"registry":{"description":"Which registry this came from","type":"string"},"repository_info":{"$ref":"#/components/schemas/contracts.RepositoryInfo"},"source_code_url":{"description":"Source repository URL","type":"string"},"updated_at":{"type":"string"},"url":{"description":"MCP endpoint for remote servers only","type":"string"}},"type":"object"},"contracts.SearchRegistryServersResponse":{"properties":{"cache":{"$ref":"#/components/schemas/contracts.RegistryCacheInfo"},"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"},"unavailable":{"$ref":"#/components/schemas/contracts.RegistryUnavailable"}},"type":"object"},"contracts.SearchResult":{"properties":{"matches":{"type":"integer"},"score":{"type":"number"},"snippet":{"type":"string"},"tool":{"$ref":"#/components/schemas/contracts.Tool"}},"type":"object"},"contracts.SearchToolsResponse":{"properties":{"query":{"type":"string"},"results":{"items":{"$ref":"#/components/schemas/contracts.SearchResult"},"type":"array","uniqueItems":false},"took":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.SecurityScanSummary":{"description":"Latest security scan results summary","properties":{"deep_scan":{"$ref":"#/components/schemas/contracts.DeepScanDescriptor"},"finding_counts":{"$ref":"#/components/schemas/contracts.FindingCounts"},"last_scan_at":{"type":"string"},"risk_score":{"description":"0-100","type":"integer"},"scanners_failed":{"type":"integer"},"scanners_run":{"description":"Scanner coverage for the primary (baseline) scan pass — informational only.\nSpec 077 US3 (FR-008/FR-014): Status is derived SOLELY from the\ndeterministic baseline findings; a failed Docker deep scanner no longer\ndowngrades a clean verdict. That failure is surfaced via DeepScan instead.","type":"integer"},"scanners_total":{"type":"integer"},"status":{"description":"\"clean\", \"warnings\", \"dangerous\", \"failed\", \"not_scanned\", \"scanning\"","type":"string"}},"type":"object"},"contracts.Server":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"authenticated":{"description":"OAuth authentication status","type":"boolean"},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges mirrors config.ServerConfig.AutoApproveToolChanges\n(MCP-2930): the per-server intent to auto-approve new/changed tools past\nthe trust baseline. Tri-state *bool — nil means \"never set\" (omitted from\nthe payload), so the Web UI toggle (MCP-2932) can distinguish unset from\nan explicit false. Read-only on the GET path; PATCH/POST accept it via\nAddServerRequest.","type":"boolean"},"command":{"type":"string"},"connected":{"type":"boolean"},"connected_at":{"type":"string"},"connecting":{"type":"boolean"},"created":{"type":"string"},"diagnostic":{"$ref":"#/components/schemas/contracts.Diagnostic"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"error_code":{"type":"string"},"expose_prompts":{"description":"ExposePrompts mirrors config.ServerConfig.ExposePrompts (F9): the per-server\nprompt-aggregation override. Tri-state *bool — nil/omitted means \"inherit\ndefault aggregation\". Surfaced on GET so a caller that PATCHed the override\ncan read it back; PATCH/POST accept it via AddServerRequest.","type":"boolean"},"forward_headers":{"description":"ForwardHeaders mirrors config.ServerConfig.ForwardHeaders (Spec 112): the\nallowlist of inbound MCP client header NAMES forwarded to this server on\ntools/call. Names only, never values. Omitted when empty.","items":{"type":"string"},"type":"array","uniqueItems":false},"generation":{"description":"Generation is the server's committed config generation (Spec 116\nFR-017): it changes whenever the server's configuration changes.","type":"integer"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"health":{"$ref":"#/components/schemas/contracts.HealthStatus"},"id":{"type":"string"},"incarnation":{"description":"Incarnation changes only when the name is removed and created again\n(Spec 116 FR-017). Approval endpoints accept it back as\nexpected_incarnation and answer 409 server_replaced on a mismatch.","type":"integer"},"init_timeout":{"description":"InitTimeout mirrors config.ServerConfig.InitTimeout (MCP-3322 / GH #760):\nthe per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override. Serialized as\na duration string (e.g. \"120s\"); nil/omitted means \"inherit the global\ndefault\". Surfaced on the GET path so clients can read back a configured\noverride; PATCH/POST accept it via AddServerRequest.","type":"string"},"isolation":{"$ref":"#/components/schemas/contracts.IsolationConfig"},"isolation_defaults":{"$ref":"#/components/schemas/contracts.IsolationDefaults"},"isolation_effective":{"$ref":"#/components/schemas/contracts.IsolationEffective"},"last_error":{"type":"string"},"last_reconnect_at":{"type":"string"},"last_retry_time":{"type":"string"},"max_concurrent_requests":{"description":"Spec 093 (GH #955) — per-server concurrency overrides, scope (c) of\nFR-020. Each setting is tri-state: nil (omitted) means \"inherit\nserver_concurrency_defaults\", 0 disables that setting for this server,\npositive overrides it. Surfaced on the GET path so a caller can read back\nwhat it set; PATCH/POST accept them via AddServerRequest. The effective\nconcurrency for a server is additionally bounded by the global aggregate\nlimiter, which is NOT an inheritance source for these fields.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_count":{"type":"integer"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets this disconnected server","type":"boolean"},"retry_count":{"type":"integer"},"retry_stopped":{"description":"RetryStopped reports that automatic reconnection has been given up for\ngood because the failure is deterministic and unrecoverable — a missing\nbinary, an image without the interpreter, an unparseable config (GH\n#1145). It is NOT ordinary exponential backoff, which keeps retrying;\nnothing will happen until the user fixes the config or restarts the\nserver. RetryStoppedCode is the stable MCPX_* code that proved it and\nRetryStoppedReason the catalog message explaining how to fix it. All three\nare omitted for servers that are healthy or still retrying.","type":"boolean"},"retry_stopped_code":{"type":"string"},"retry_stopped_reason":{"type":"string"},"security_scan":{"$ref":"#/components/schemas/contracts.SecurityScanSummary"},"should_retry":{"type":"boolean"},"source_registry_id":{"description":"MCP-901 — registry provenance of an upstream that was added from a\nregistry. SourceRegistryID names the source registry (empty for\nmanually-configured servers); SourceRegistryProvenance is the trust tag\nrecorded at add time (\"official/trusted\" or \"custom/unverified\"). Both\nare projected from config.ServerConfig so the approval/quarantine view\ncan render an \"added from \u003cregistry\u003e · unverified\" origin badge. Optional\nand omitted when empty — clients that pre-date this treat them as absent.","type":"string"},"source_registry_provenance":{"type":"string"},"status":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires (ISO 8601)","type":"string"},"tool_count":{"type":"integer"},"tool_list_token_size":{"description":"Token size for this server's tools","type":"integer"},"trust_mode":{"description":"TrustMode mirrors config.ServerConfig.TrustMode (spec 086): the per-server\ntrust tier (\"auto\"/\"scan\"/\"manual\"). Surfaced on the GET path so clients can\nread back the persisted mode; PATCH/POST accept it via AddServerRequest.\nOmitted when empty (server predates the field / relies on legacy flags).","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"user_logged_out":{"description":"True if user explicitly logged out (prevents auto-reconnection)","type":"boolean"},"working_dir":{"type":"string"}},"type":"object"},"contracts.ServerActionResponse":{"properties":{"action":{"type":"string"},"async":{"type":"boolean"},"server":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ServerStats":{"properties":{"connected_servers":{"type":"integer"},"docker_containers":{"type":"integer"},"quarantined_servers":{"type":"integer"},"token_metrics":{"$ref":"#/components/schemas/contracts.ServerTokenMetrics"},"total_servers":{"type":"integer"},"total_tools":{"type":"integer"}},"type":"object"},"contracts.ServerTokenMetrics":{"properties":{"average_query_result_size":{"description":"Typical retrieve_tools output (tokens)","type":"integer"},"estimated":{"description":"Estimated (Spec 109-k FR-070-ish, url-filter-contract.md / audit F-Token):\ntrue while AverageQueryResultSize is a synthetic simulation (a sample of\nthe first ` + "`" + `tools_limit` + "`" + ` tools' schemas — no real retrieve_tools call has\ncompleted yet in this runtime's usage aggregate); false once at least one\nreal retrieve_tools call has, at which point AverageQueryResultSize is\nderived from the real observed average response size instead. The Web\nUI and macOS render an \"estimate\" label while this is true.","type":"boolean"},"per_server_tool_list_sizes":{"additionalProperties":{"type":"integer"},"description":"Token size per server","type":"object"},"saved_tokens":{"description":"Difference","type":"integer"},"saved_tokens_percentage":{"description":"Percentage saved","type":"number"},"total_server_tool_list_size":{"description":"All upstream tools combined (tokens)","type":"integer"}},"type":"object"},"contracts.SuccessResponse":{"properties":{"data":{"type":"object"},"success":{"type":"boolean"}},"type":"object"},"contracts.Tier":{"description":"Tier is computed by AnnotationTier (Spec 109 FR-028/X11) from\nAnnotations — read|write|destructive|unannotated. Set by every producer\nof a Tool (enrichServerTools, the global tools handler); never left for\na consuming surface to compute.","type":"string","x-enum-varnames":["TierRead","TierWrite","TierDestructive","TierUnannotated","TierUnknown"]},"contracts.TokenMetrics":{"description":"Token usage metrics (nil for older records)","properties":{"encoding":{"description":"Encoding used (e.g., cl100k_base)","type":"string"},"estimated_cost":{"description":"Optional cost estimate","type":"number"},"input_tokens":{"description":"Tokens in the request","type":"integer"},"model":{"description":"Model used for tokenization","type":"string"},"output_tokens":{"description":"Tokens in the response","type":"integer"},"total_tokens":{"description":"Total tokens (input + output)","type":"integer"},"truncated_tokens":{"description":"Tokens removed by truncation","type":"integer"},"was_truncated":{"description":"Whether response was truncated","type":"boolean"}},"type":"object"},"contracts.Tool":{"properties":{"access":{"$ref":"#/components/schemas/contracts.ToolAccess"},"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"approval_status":{"type":"string"},"config_denied":{"description":"ConfigDenied is true when the tool is denied by the server's static\nenabled_tools / disabled_tools config. The user cannot override this toggle.","type":"boolean"},"description":{"type":"string"},"disabled":{"description":"Disabled mirrors ToolApprovalRecord.Disabled so per-tool enable state is\navailable without a second round-trip to the approvals endpoint. Absent\nin the JSON when false (default) to keep responses compact.","type":"boolean"},"hash":{"description":"Hash is the tool's current stored hash rendered in the preflight pin\nformat \"sha256/v{N}:{hex}\" (Spec 098 FR-011), where N is the approval\nrecord's HashSchemaVersion. It is the authoring surface for\n` + "`" + `POST /api/v1/preflight` + "`" + ` pins and ` + "`" + `mcpproxy tools preflight --pin` + "`" + `:\ncopy the value straight into a pin.\n\nDisclosure is OPERATOR TIER ONLY — same rule as the preflight per-tool\nresult. The field is omitted for agent-token callers and for tools with\nno stored hash (no approval record yet, or a record written before\nhashes existed).","type":"string"},"held_reason":{"description":"HeldReason, HeldVerdict and HeldSignals mirror the same-named fields on\nstorage.ToolApprovalRecord: the offline-scan evidence that made\ntrust_mode: scan hold this tool for review (spec 086 FR-018). HeldSignals\nnames the matched deterministic check ids, e.g.\n\"tpa.TPA-2026-0001.hidden_instruction\", so a reviewer can see WHY the tool\nis held. All three are omitted for tools that are not held by the scan gate\n(including every record written before the field existed).","type":"string"},"held_signals":{"items":{"type":"string"},"type":"array","uniqueItems":false},"held_verdict":{"type":"string"},"last_used":{"type":"string"},"name":{"type":"string"},"profile_tier":{"description":"ProfileTier and Access are present only in a view-as listing (a tools\nlisting with ?client= or ?profile=, Spec 108 FR-032). ProfileTier is the\ntool's tier under the viewed subject's profile (Tier above stays the\nintrinsic tier); Access is the subject's verdict for the tool. Absent\notherwise, so an ordinary listing is byte-identical to before.","type":"string","x-enum-varnames":["TierRead","TierWrite","TierDestructive","TierUnannotated","TierUnknown"]},"schema":{"type":"object"},"server_incarnation":{"description":"ServerIncarnation is the committed incarnation of the server this row\nwas read for (Spec 116 FR-017), captured before the tools were fetched.\nA client echoes it as expected_incarnation on a per-tool decision, so a\nstale row is refused (409 server_replaced) after a same-name re-add.\nOmitted when the server has no committed incarnation.","type":"integer"},"server_name":{"type":"string"},"tier":{"$ref":"#/components/schemas/contracts.Tier"},"usage":{"type":"integer"}},"type":"object"},"contracts.ToolAccess":{"properties":{"callable":{"type":"boolean"},"reason":{"type":"string"},"visible":{"type":"boolean"}},"type":"object"},"contracts.ToolAnnotation":{"description":"Tool behavior hints snapshot","properties":{"destructiveHint":{"type":"boolean"},"idempotentHint":{"type":"boolean"},"openWorldHint":{"type":"boolean"},"readOnlyHint":{"type":"boolean"},"title":{"type":"string"}},"type":"object"},"contracts.ToolCallRecord":{"description":"The new tool call record","properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"arguments":{"description":"Tool arguments","type":"object"},"arguments_truncated":{"description":"ArgumentsTruncated marks Arguments as a placeholder rather than the\narguments the tool was called with. Replaying such a record without\nsupplying arguments explicitly is refused.","type":"boolean"},"config_path":{"description":"Active config file path","type":"string"},"duration":{"description":"Duration in nanoseconds","type":"integer"},"error":{"description":"Error message (failure only)","type":"string"},"execution_type":{"description":"\"direct\" or \"code_execution\"","type":"string"},"id":{"description":"Unique identifier","type":"string"},"mcp_client_name":{"description":"MCP client name from InitializeRequest","type":"string"},"mcp_client_version":{"description":"MCP client version","type":"string"},"mcp_session_id":{"description":"MCP session identifier","type":"string"},"metrics":{"$ref":"#/components/schemas/contracts.TokenMetrics"},"parent_call_id":{"description":"Links nested calls to parent code_execution","type":"string"},"request_id":{"description":"Request correlation ID","type":"string"},"response":{"description":"Tool response (success only)","type":"object"},"response_bytes":{"description":"Marshalled response size before truncation","type":"integer"},"response_truncated":{"description":"ResponseTruncated and ResponseBytes describe a STORAGE-side cut (#1176):\nthe caller received the response whole, and only the persisted copy was\nshortened to tool_call_max_response_size. When ResponseTruncated is true\nthe Response object carries {truncated, original_bytes, preview, note}\ninstead of the upstream result, and ResponseBytes is its size before the\ncut.","type":"boolean"},"server_id":{"description":"Server identity hash","type":"string"},"server_name":{"description":"Human-readable server name","type":"string"},"timestamp":{"description":"When the call was made","type":"string"},"tool_name":{"description":"Tool name (without server prefix)","type":"string"}},"type":"object"},"contracts.UpdateInfo":{"description":"Update information (if available)","properties":{"available":{"description":"Whether an update is available","type":"boolean"},"behind_summary":{"description":"Spec 079 FR-002 — how far behind the running build is. All four are\nadditive (FR-021) and absent when the delta could not be resolved, in\nwhich case every surface renders its pre-delta wording.","type":"string"},"check_error":{"description":"Error message if update check failed","type":"string"},"checked_at":{"description":"When the update check was performed","type":"string"},"install_channel":{"description":"Detected install channel (homebrew, dmg, deb, rpm, docker, go-install, windows-installer, tarball, unknown) — Spec 079 FR-008","type":"string"},"is_prerelease":{"description":"Whether the latest version is a prerelease","type":"boolean"},"latest_version":{"description":"Latest version available (e.g., \"v1.2.3\")","type":"string"},"nudges_suppressed":{"description":"UI surfaces must stay quiet (CI / non-interactive context); machine-readable fields still report the facts — Spec 079 FR-019","type":"boolean"},"release_url":{"description":"URL to the release page","type":"string"},"releases_behind":{"description":"Releases on the offered channel between the running and offered versions","type":"integer"},"releases_behind_saturated":{"description":"ReleasesBehind is a lower bound: the running build predates the scanned release window","type":"boolean"},"update_command":{"description":"One-line update command for the channel; only set when an update is available and the channel has one — Spec 079 FR-009","type":"string"},"weeks_behind":{"description":"Whole weeks between the two releases' publish dates; 0 is a real value, absent means unknown","type":"integer"}},"type":"object"},"contracts.UpdatePolicy":{"description":"UpdatePolicy is the effective, hot-reloadable update policy (Spec 092\nFR-015). Always present: the ` + "`" + `update` + "`" + ` object above is omitted both when\nupdate checking is disabled AND when no check has produced a result\nyet, so its absence cannot tell a client whether it is allowed to run\nits own (e.g. Sparkle feed) check. This field states the answer.","properties":{"channel":{"description":"Channel is the tracked release channel: \"stable\" or \"rc\".","type":"string"},"enabled":{"description":"Enabled is the effective automatic-check kill switch: update_check.enabled\nwith MCPPROXY_DISABLE_AUTO_UPDATE=true winning over it. A user-initiated\n\"Check for Updates\" stays available regardless.","type":"boolean"},"nudges_suppressed":{"description":"NudgesSuppressed asks UI surfaces to stay quiet (CI / non-interactive)\nwhile machine-readable fields keep reporting the facts.","type":"boolean"}},"type":"object"},"contracts.UpstreamError":{"properties":{"error_message":{"type":"string"},"server_name":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.UsageAggregateResponse":{"properties":{"freshness_ms":{"description":"age of the underlying snapshot in ms","type":"integer"},"generated_at":{"type":"string"},"other":{"$ref":"#/components/schemas/contracts.UsageOtherBucket"},"timeline":{"items":{"$ref":"#/components/schemas/contracts.UsageTimeBucket"},"type":"array","uniqueItems":false},"token_source":{"description":"\"bytes\" (size-based proxy, FR-006)","type":"string"},"tokens_saved":{"description":"echoed from ServerTokenMetrics (FR-007)","type":"integer"},"tokens_saved_estimated":{"description":"TokensSavedEstimated echoes ServerTokenMetrics.Estimated (Spec 109-k):\ntrue while TokensSaved is a synthetic simulation rather than derived\nfrom a real retrieve_tools call. Dropped (false, the zero value) for a\nscoped caller along with TokensSaved itself, above.","type":"boolean"},"tokens_saved_percentage":{"type":"number"},"tools":{"items":{"$ref":"#/components/schemas/contracts.UsageToolStat"},"type":"array","uniqueItems":false},"total_calls":{"description":"TotalCalls and TotalErrors are the headline counts for the window: the sum\nof the timeline this same response carries, so the tiles and the histogram\nunder them cannot disagree. They are NOT the sum of Tools — that list is\nlifetime-cumulative, upstream-only and truncated to top-N, and summing it\nclient-side is what made the Usage tab print a third number for the same\n24 hours (audit finding F1, #1046). The population is\nstorage.CountsAsCall, shared with ActivitySummaryResponse.CallCount.\n\nTwo bounds on how exactly this matches the Activity Log's own count.\nBoth are bounded and disclosed, unlike the population mismatch they\nreplace, which was unbounded and silent:\n\n - Window granularity is the timeline's: whole hour buckets, so the span\n is the requested window rounded up to a bucket edge.\n - This response is served from a snapshot behind a short read cache\n (observability.usage_cache_ttl, 5s by default) so the endpoint never\n scans the activity log per request, while the summary endpoint counts\n live. Calls that land inside that window appear on the Activity Log\n first. FreshnessMs and GeneratedAt say how old the figures are, and\n the Usage tab prints it (\"Updated 3s ago\").","type":"integer"},"total_errors":{"type":"integer"},"window":{"type":"string"}},"type":"object"},"contracts.UsageOtherBucket":{"description":"present only when the list was truncated to top-N","properties":{"calls":{"type":"integer"},"tools_folded":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageTimeBucket":{"properties":{"calls":{"type":"integer"},"errors":{"type":"integer"},"start":{"type":"string"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageToolStat":{"properties":{"avg_req_bytes":{"description":"null when no sized request calls","type":"integer"},"avg_resp_bytes":{"description":"null when sized_calls == 0 (only legacy 0-byte calls)","type":"integer"},"blocked":{"type":"integer"},"calls":{"type":"integer"},"error_rate":{"type":"number"},"errors":{"type":"integer"},"last_used":{"type":"string"},"p50_exceeds":{"type":"boolean"},"p50_ms":{"description":"P50Ms and P95Ms are read off a fixed latency histogram, so they are BUCKET\nBOUNDS, not measured durations: the true percentile is at or below the\nvalue, and a client must render it as a bound (\"≤ 5 ms\"). P50Exceeds /\nP95Exceeds flip that reading for the unbounded overflow bucket, where the\nvalue is the last bound and the truth is above it (\"\u003e 10 s\").","type":"integer"},"p95_exceeds":{"type":"boolean"},"p95_ms":{"type":"integer"},"rejected":{"description":"spec 093: shed by a concurrency limit; never executed, so excluded from calls/latency","type":"integer"},"server":{"type":"string"},"sized_calls":{"description":"calls with known response size (basis for avg_resp_bytes)","type":"integer"},"tool":{"type":"string"},"total_req_bytes":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.ValidateConfigResponse":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false},"valid":{"type":"boolean"}},"type":"object"},"contracts.ValidationError":{"properties":{"field":{"type":"string"},"message":{"type":"string"}},"type":"object"},"contracts.ViewAsCounts":{"description":"Counts is present only for a NON-administrator profile view-as (Spec 108\nFR-032): the response then lists only the visible rows, and this is the\nonly trace of the rest, with no per-server, per-tier or per-reason\nbreakdown.","properties":{"hidden":{"type":"integer"},"visible":{"type":"integer"}},"type":"object"},"data":{"properties":{"data":{"$ref":"#/components/schemas/contracts.InfoResponse"}},"type":"object"},"httpapi.AccessExplanationData":{"properties":{"first_failure":{"type":"string","x-enum-varnames":["StepCredential","StepProfile","StepServerInScope","StepToolRule","StepTierCap","StepTokenPermission","StepGlobalGate","StepServerState","StepToolApproval"]},"fixes":{"items":{"$ref":"#/components/schemas/runtime.Fix"},"type":"array","uniqueItems":false},"profile":{"$ref":"#/components/schemas/runtime.ExplainProfileView"},"steps":{"items":{"$ref":"#/components/schemas/runtime.ExplainStepView"},"type":"array","uniqueItems":false},"subject":{"$ref":"#/components/schemas/runtime.ExplainSubjectView"},"tool":{"type":"string"},"verdict":{"$ref":"#/components/schemas/profile.ExplainVerdict"}},"type":"object"},"httpapi.AddServerRequest":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve\nnew/changed tools past the trust baseline (MCP-2930). Tri-state *bool:\na nil pointer means \"leave unchanged\" on PATCH; a present value\n(including false) is applied. Mirrors config.ServerConfig's *bool\nsemantics — do NOT collapse to a plain bool, or an omitted field would\nsilently reset a previously-set value.","type":"boolean"},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"expose_prompts":{"description":"ExposePrompts is the per-server override for prompt aggregation (F9):\nwhether this server's advertised MCP prompts are merged into mcpproxy's\nprompts/list. Tri-state *bool mirroring config.ServerConfig.ExposePrompts —\na nil pointer means \"leave unchanged\" on PATCH (and \"inherit the default\naggregate behavior\" on create); a present value (including false) is applied.","type":"boolean"},"forward_headers":{"description":"ForwardHeaders is the per-server allowlist of inbound MCP client header\nNAMES forwarded to this server on tools/call (Spec 112). Names only, never\nvalues. On PATCH a nil slice (field omitted) leaves the stored allowlist\nunchanged and an empty array ([]) clears it. Invalid, denied, duplicate\nand static-header-colliding names are rejected with 400.","items":{"type":"string"},"type":"array","uniqueItems":false},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"init_timeout":{"description":"InitTimeout is the per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override\n(MCP-3322 / GH #760), serialized as a duration string (e.g. \"120s\"). A nil\npointer means \"leave unchanged\" on PATCH; a present value is applied.\nMirrors config.ServerConfig.InitTimeout's *Duration tri-state.","type":"string"},"isolation":{"$ref":"#/components/schemas/httpapi.IsolationRequest"},"max_concurrent_requests":{"description":"MaxConcurrentRequests / QueueSize / QueueTimeout are the per-server\nconcurrency overrides (spec 093 / GH #955, FR-020 scope (c)). Each is\ntri-state: a nil pointer means \"leave unchanged\" on PATCH and \"inherit\nserver_concurrency_defaults\" on create; an explicit 0 disables that\nsetting for this server; a positive value overrides it. Do NOT collapse\nthem to plain values — an omitted field would then silently reset a\nconfigured limit.","type":"integer"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"type":"boolean"},"trust_mode":{"description":"TrustMode is the per-server trust tier (spec 086): \"auto\", \"scan\", or\n\"manual\". Empty means \"leave unchanged\" on PATCH (and inherit the migrated\ndefault on create). A non-empty value is applied to ServerConfig.TrustMode\nand resolved by EffectiveTrustMode (an unrecognized value fails closed to\nmanual). This is the REST seam for changing the trust tier via\nPOST/PATCH /api/v1/servers.","type":"string"},"url":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.BindingGuardResponse":{"properties":{"bindings":{"items":{"$ref":"#/components/schemas/httpapi.GuardBinding"},"type":"array","uniqueItems":false},"code":{"description":"binding_bypassable_without_auth","type":"string"},"error":{"description":"Human-readable, byte-stable refusal text","type":"string"},"fixes":{"items":{"$ref":"#/components/schemas/httpapi.GuardFixOption"},"type":"array","uniqueItems":false},"request_id":{"type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.BulkAssignRequest":{"properties":{"from_profile":{"type":"string"},"mode":{"type":"string"},"to_profile":{"type":"string"}},"type":"object"},"httpapi.BulkAssignResponse":{"properties":{"moved":{"items":{"type":"string"},"type":"array","uniqueItems":false},"skipped":{"items":{"$ref":"#/components/schemas/httpapi.BulkAssignSkipped"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.BulkAssignSkipped":{"properties":{"client_id":{"type":"string"},"code":{"type":"string"},"error":{"type":"string"}},"type":"object"},"httpapi.BundleRollbackRefusedResponse":{"properties":{"error":{"type":"string"},"error_code":{"type":"string"},"rejection":{"$ref":"#/components/schemas/scanner.BundleRejection"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"httpapi.CanonicalConfigPath":{"properties":{"description":{"description":"Brief description","type":"string"},"exists":{"description":"Whether the file exists","type":"boolean"},"format":{"description":"Format identifier (e.g., \"claude_desktop\")","type":"string"},"name":{"description":"Display name (e.g., \"Claude Desktop\")","type":"string"},"os":{"description":"Operating system (darwin, windows, linux)","type":"string"},"path":{"description":"Full path to the config file","type":"string"}},"type":"object"},"httpapi.CanonicalConfigPathsResponse":{"properties":{"os":{"description":"Current operating system","type":"string"},"paths":{"description":"List of canonical config paths","items":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPath"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ClientBindingErrorResponse":{"properties":{"code":{"type":"string"},"error":{"type":"string"},"field":{"type":"string"},"request_id":{"type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.ClientBindingResponse":{"properties":{"client":{"$ref":"#/components/schemas/httpapi.clientPresence"},"warnings":{"items":{"$ref":"#/components/schemas/httpapi.ClientWarning"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ClientSnippet":{"properties":{"generic_http":{"type":"string"},"header_name":{"type":"string"}},"type":"object"},"httpapi.ClientWarning":{"properties":{"action":{"$ref":"#/components/schemas/runtime.WarningAction"},"bindings":{"items":{"$ref":"#/components/schemas/runtime.BindingRef"},"type":"array","uniqueItems":false},"client_id":{"type":"string"},"code":{"$ref":"#/components/schemas/profile.WarningCode"},"fixes":{"items":{"$ref":"#/components/schemas/runtime.GuardFix"},"type":"array","uniqueItems":false},"message":{"type":"string"},"severity":{"$ref":"#/components/schemas/profile.WarningSeverity"}},"type":"object"},"httpapi.ConnectConflictResponse":{"properties":{"action":{"description":"already_exists | precondition_failed","type":"string"},"data":{"$ref":"#/components/schemas/connect.ConnectResult"},"error":{"description":"Human-readable message","type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.ConnectRequest":{"properties":{"force":{"description":"Overwrite existing entry","type":"boolean"},"keyless":{"type":"boolean"},"mode":{"type":"string"},"precondition_token":{"description":"PreconditionToken is the opaque token from the preview this write was\nconfirmed against (Spec 091 FR-005). When present, the core rechecks it\nat write time and responds 409 with action \"precondition_failed\" —\nwriting nothing — if the config or the entry MCPProxy would write has\ndrifted since; the caller then re-previews instead of retrying. Absent\nmeans exactly the pre-091 behavior. A replace-classified flow sends this\nTOGETHER with force=true: the token, not the absence of force, is the\noverwrite safety.","type":"string"},"profile":{"description":"Profile, Mode and Keyless are the client-credential intent (Spec 108\nFR-024). Profile names the profile the client's credential binds to;\n\"\" is the built-in All servers scope. Omitted means \"not specified\": a\nfresh credential defaults to All servers and a reconnect keeps the\nclient's existing binding (a reconnect never silently widens a locked\nclient). Mode is locked|switchable (default locked for a named profile,\nswitchable for All servers). Keyless writes a credential-less entry and\nis only possible while require_mcp_auth is off; it cannot carry a\nprofile or mode.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.CreateClientRequest":{"properties":{"display_name":{"type":"string"},"expires_in":{"description":"ExpiresIn is a duration like 30d or 720h; default and cap are 365 days.","type":"string"},"id":{"type":"string"},"mode":{"type":"string"},"profile":{"type":"string"},"purpose":{"description":"Purpose is the stated, unenforced task brief (Spec 115), at most 500\ncharacters; screened for secrets before anything is stored.","type":"string"}},"type":"object"},"httpapi.CreateClientResponse":{"properties":{"client":{"$ref":"#/components/schemas/httpapi.clientPresence"},"credential":{"type":"string"},"snippet":{"$ref":"#/components/schemas/httpapi.ClientSnippet"}},"type":"object"},"httpapi.EffectiveToolsData":{"properties":{"counts":{"$ref":"#/components/schemas/runtime.EffectiveCounts"},"profile":{"type":"string"},"stale_classification_reasons":{"additionalProperties":{"type":"string"},"description":"StaleClassificationReasons maps each stale classify entry to why it no\nlonger applies: profile.StaleClassificationAnnotated or\nprofile.StaleClassificationMissing. It is computed over the UNFILTERED\ntool set, so a server or reason filter never changes it. Administrators\nonly.","type":"object"},"stale_classifications":{"description":"StaleClassifications lists classify entries for tools that are now\nannotated or no longer exist (FR-005). Administrators only.","items":{"type":"string"},"type":"array","uniqueItems":false},"tools":{"items":{"$ref":"#/components/schemas/runtime.EffectiveTool"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.EnvFieldPreview":{"properties":{"empty_or_placeholder":{"type":"boolean"},"name":{"type":"string"},"secret_like":{"type":"boolean"},"value_present":{"type":"boolean"}},"type":"object"},"httpapi.FinalizeClientResponse":{"properties":{"client":{"$ref":"#/components/schemas/httpapi.clientPresence"},"rotation":{"$ref":"#/components/schemas/httpapi.RotationState"}},"type":"object"},"httpapi.ForgetClientResponse":{"properties":{"disconnect_error":{"type":"string"},"disconnected":{"type":"boolean"},"revoked":{"type":"string"}},"type":"object"},"httpapi.GuardBinding":{"properties":{"client_id":{"type":"string"},"mode":{"type":"string"},"profile":{"type":"string"},"token_name":{"type":"string"}},"type":"object"},"httpapi.GuardFixOption":{"properties":{"kind":{"type":"string"},"target":{"type":"string"}},"type":"object"},"httpapi.HeaderFieldPreview":{"properties":{"empty_or_placeholder":{"type":"boolean"},"name":{"type":"string"},"secret_like":{"type":"boolean"},"value_present":{"type":"boolean"}},"type":"object"},"httpapi.ImportFromPathRequest":{"properties":{"format":{"description":"Optional format hint","type":"string"},"path":{"description":"File path to import from","type":"string"},"rename":{"additionalProperties":{"type":"string"},"description":"Rename maps a server name → new name. Applied after parsing so the\ncaller can disambiguate cross-source name collisions (Spec 046 v2 —\ne.g. \"mcpproxy\" → \"mcpproxy_claude_code\"). Keys are matched against\neither the raw source name (OriginalName) or the sanitized name shown\nin the preview (Server.Name); these differ for names that need\nsanitizing (e.g. \"Figma Desktop\" → \"Figma_Desktop\"). Keys not present\nin the imported set are ignored.","type":"object"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportRequest":{"properties":{"allow_paste_fallback":{"description":"AllowPasteFallback opts into detecting a bare URL or a single command\nline (FR-064) when Format/format detection would otherwise fail — see\nconfigimport.ImportOptions.AllowPasteFallback for why this must stay\nopt-in (review round 4 F-E). Only the interactive Paste tab sets this;\nevery other caller of this endpoint (the general \"Import config\"\npanel, or a direct API call) leaves it false and gets a clear\n\"unable to detect configuration format\" error for a plain one-liner\ninstead of it being silently guessed at and, on apply, added as a\nreal server with no confirmation step.","type":"boolean"},"content":{"description":"Raw JSON or TOML content","type":"string"},"env_override":{"additionalProperties":{"type":"string"},"description":"EnvOverride/HeaderOverride (Spec 109 FR-064/065, PR review round 4\nF-A/F-D fix): the Paste tab's per-field Value/Secret edits — a plain\nvalue the user typed, or a keyring ref path if they chose Secret —\nkeyed by field name. Applied to the matching imported server's\nEnv/Headers ONLY when preview=false, directly on the server this\nrequest's own raw Content parses to server-side. This is what lets\nthe apply call carry the user's edited env/header values without\never round-tripping the redacted preview: url/command/args on apply\nalways come from re-parsing Content here, never from a client-held\npreview response, so a credential embedded in a URL query param or\nargv flag (which the preview necessarily redacted for display) is\nnever overwritten with the masked placeholder.","type":"object"},"format":{"description":"Optional format hint","type":"string"},"header_override":{"additionalProperties":{"type":"string"},"type":"object"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportResponse":{"properties":{"failed":{"items":{"$ref":"#/components/schemas/configimport.FailedServer"},"type":"array","uniqueItems":false},"format":{"type":"string"},"format_name":{"type":"string"},"imported":{"items":{"$ref":"#/components/schemas/httpapi.ImportedServerResponse"},"type":"array","uniqueItems":false},"skipped":{"items":{"$ref":"#/components/schemas/configimport.SkippedServer"},"type":"array","uniqueItems":false},"summary":{"$ref":"#/components/schemas/configimport.ImportSummary"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportedServerResponse":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"env":{"items":{"$ref":"#/components/schemas/httpapi.EnvFieldPreview"},"type":"array","uniqueItems":false},"fields_skipped":{"items":{"type":"string"},"type":"array","uniqueItems":false},"headers":{"items":{"$ref":"#/components/schemas/httpapi.HeaderFieldPreview"},"type":"array","uniqueItems":false},"name":{"type":"string"},"original_name":{"type":"string"},"protocol":{"type":"string"},"source_format":{"type":"string"},"summary":{"description":"Summary, Tags, Env and Headers are the Spec 109 FR-064 preview\nenrichment (contracts/rest-api.md \"Import preview\"). Summary and Tags\nare built from the already-redacted URL/Command/Args above, so a\nsecret embedded in argv or a URL query never reaches Summary either.\nEnv/Headers never carry the raw value — only its presence and two\nbooleans a surface uses to default the Value/Secret toggle (FR-065).","type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.IsolationRequest":{"description":"Isolation carries per-server Docker isolation overrides (enabled,\nmode_override, image, network_mode, extra_args, working_dir). A nil\npointer means \"do not touch isolation config\". A present object is\napplied field-by-field ON TOP of the persisted overrides, so omitting a\nfield leaves it alone; clear an individual override by sending it\nexplicitly (` + "`" + `\"enabled\": null` + "`" + `, ` + "`" + `\"image\": \"\"` + "`" + `).","properties":{"enabled":{"description":"Enabled exists ONLY to detect and reject an echoed-back read. It is the\neffective state on the read surface and is never writable; see validate().","type":"boolean"},"enabled_override":{"description":"EnabledOverride is the tri-state per-server override — the RAW value, the\nsame one reads return as ` + "`" + `enabled_override` + "`" + `. It has THREE meaningful wire\nstates, and collapsing them is what silently un-isolated servers\n(GH #1142):\n - absent → leave the persisted override untouched\n - null → clear the override, back to inheriting the global\n - true / false → set an explicit opt-in / opt-out","type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"mode_override":{"description":"ModeOverride sets ` + "`" + `isolation.mode` + "`" + ` (\"docker\" | \"sandbox\" | \"none\").\nnil leaves the persisted value alone; an empty string clears it. An\nunrecognized value is rejected with a 400 rather than persisted.","type":"string"},"network_mode":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.OnboardingMarkRequest":{"properties":{"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value. The stored enum is wider (Spec 080\nFR-001): a \"skipped\" request for a previously untouched connect step\nis upgraded server-side to \"completed_external\" when the install\nshows positive evidence of an external connection (Spec 080 FR-002).\n\"completed_external\" is NOT accepted from clients — it must never be\npersisted without that server-verified evidence (edge case: \"never\nguess completed_external without positive evidence\").","type":"string"},"connected_client_id":{"description":"ConnectedClientID records a successful connect write for this client id\n(Spec 109-b FR-042, review round 6). The REST connect endpoint\n(POST /api/v1/connect/{client}) already records this itself on success;\nthis field exists so ` + "`" + `mcpproxy connect` + "`" + ` — which writes the client's\nconfig file directly, without going through that endpoint, so the\ncommand still works when no daemon is running — can relay the same\nevent to a daemon that IS running, keeping ClientConnectedAt in sync\nacross both surfaces. Must be a known id from the fixed connect client\nregistry (internal/connect.GetAllClients); any other value is rejected,\nmatching the field's \"bounded by the registry\" invariant\n(data-model.md §7).","type":"string"},"disconnected_client_id":{"description":"DisconnectedClientID relays a successful local CLI disconnect to a running\ndaemon, which cannot observe the CLI's direct config-file edit itself.\nLike ConnectedClientID, it is bounded to the fixed client registry.","type":"string"},"engaged":{"description":"Engaged marks the wizard as engaged (completed or explicitly skipped).\nOnce true, the wizard does not auto-show again.","type":"boolean"},"mark_shown":{"description":"MarkShown records the wizard's first display time if not already set.","type":"boolean"},"server_step_status":{"description":"ServerStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value.","type":"string"}},"type":"object"},"httpapi.OnboardingStateResponse":{"properties":{"configured_server_count":{"description":"ConfiguredServerCount is the number of upstream MCP servers configured\nin mcpproxy (counts both enabled and disabled).","type":"integer"},"connected_client_count":{"description":"ConnectedClientCount is the number of supported clients currently\npointing at mcpproxy.","type":"integer"},"connected_client_ids":{"description":"ConnectedClientIDs are the identifiers of supported clients currently\npointing at mcpproxy. Drawn exclusively from the fixed adapter table —\nuser-entered values never appear here.","items":{"type":"string"},"type":"array","uniqueItems":false},"first_mcp_client_ever":{"description":"FirstMCPClientEver is true once any MCP client has successfully completed\nan ` + "`" + `initialize` + "`" + ` round-trip with this mcpproxy. Sourced from the Spec 044\nactivation bucket. Drives the Verify tab's \"green check\" state.","type":"boolean"},"has_configured_server":{"description":"HasConfiguredServer is true if at least one upstream MCP server is\nconfigured (regardless of current connection health).","type":"boolean"},"has_connected_client":{"description":"HasConnectedClient is true if at least one supported AI client currently\nhas mcpproxy registered in its config.","type":"boolean"},"has_usable_server":{"description":"HasUsableServer is true once at least one enabled, non-quarantined\nserver has usable health and at least one approved (non-disabled) tool.\nThis is the real \"the wizard has something to try\" signal:\nHasConfiguredServer only means a server entry exists, even while every\none of them sits quarantined, requires sign-in, or has zero approved\ntools. The Servers step and Setup badge use this instead of\nHasConfiguredServer, which is kept above for compatibility.\nHealthStatus.Usable is the shared\nreadiness contract used by every UI surface (Spec 109-c, tasks.md T051).","type":"boolean"},"incomplete_tab_count":{"description":"IncompleteTabCount is the number of wizard tabs whose state is incomplete.\nDrives the sidebar Setup entry's badge. Formula:\n +1 if HasConnectedClient == false\n +1 if HasUsableServer == false\n +1 if FirstMCPClientEver == false","type":"integer"},"mcp_clients_seen_ever":{"description":"MCPClientsSeenEver is the capped list of recognized client names that\nhave ever called this mcpproxy. Names come from the MCP ` + "`" + `initialize` + "`" + `\npayload's ` + "`" + `clientInfo.name` + "`" + ` field, sanitized. Surfaces on the Verify tab\nso the user can see whether their real IDE — not a test client — has\nconnected.","items":{"type":"string"},"type":"array","uniqueItems":false},"should_show_wizard":{"description":"ShouldShowWizard is the derived flag the frontend uses to decide\nwhether to auto-show. True when not engaged and IncompleteTabCount \u003e 0\n(Spec 046 v2 — semantics widened to also count the Verify tab).","type":"boolean"},"state":{"$ref":"#/components/schemas/storage.OnboardingState"},"usable_servers":{"description":"UsableServers lists the names behind HasUsableServer, for the Verify\nstep's suggested-prompt generator (FR-042): prompts are only built from\ntools of servers in this list, never from a quarantined or toolless one.","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ProfileConflictResponse":{"properties":{"code":{"type":"string"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"description":"Always false","type":"boolean"},"used_by":{"$ref":"#/components/schemas/httpapi.ProfileUsedByData"}},"type":"object"},"httpapi.ProfileDeleteData":{"properties":{"anonymous_profile_moved_to":{"type":"string"},"deleted":{"type":"string"},"moved":{"$ref":"#/components/schemas/runtime.MovedRefs"}},"type":"object"},"httpapi.ProfileListData":{"properties":{"anonymous_profile":{"type":"string"},"profiles":{"items":{"$ref":"#/components/schemas/runtime.ProfileView"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ProfileRenameData":{"properties":{"moved":{"$ref":"#/components/schemas/runtime.MovedRefs"},"profile":{"$ref":"#/components/schemas/runtime.ProfileView"}},"type":"object"},"httpapi.ProfileTryData":{"properties":{"hidden":{"items":{"$ref":"#/components/schemas/runtime.TryHidden"},"type":"array","uniqueItems":false},"hidden_by_profile":{"type":"integer"},"hidden_truncated":{"type":"boolean"},"results":{"items":{"additionalProperties":{},"type":"object"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ProfileUsedByData":{"properties":{"anonymous_profile":{"type":"boolean"},"clients":{"items":{"$ref":"#/components/schemas/runtime.UsedByClient"},"type":"array","uniqueItems":false},"tokens":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ProfileViewData":{"properties":{"blocked_24h":{"type":"integer"},"calls_24h":{"type":"integer"},"code_execution":{"type":"boolean"},"description":{"type":"string"},"effective_code_execution":{"type":"boolean"},"effective_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"effective_unannotated":{"type":"string"},"is_legacy":{"type":"boolean"},"management_tools":{"type":"boolean"},"max_tier":{"type":"string"},"name":{"type":"string"},"servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"switchable_to":{"items":{"type":"string"},"type":"array","uniqueItems":false},"title":{"type":"string"},"tool_count":{"description":"ToolCount is the v2 field: indexed tools on the effective servers.\nDeprecated: use tool_counts.","type":"integer"},"tool_counts":{"$ref":"#/components/schemas/runtime.ToolCounts"},"tools":{"$ref":"#/components/schemas/config.ProfileToolRules"},"unannotated":{"type":"string"},"used_by":{"$ref":"#/components/schemas/runtime.UsedBy"}},"type":"object"},"httpapi.ProfileWriteData":{"properties":{"profile":{"$ref":"#/components/schemas/runtime.ProfileView"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.PutClientBindingRequest":{"properties":{"mode":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.RenameProfileRequest":{"properties":{"new_name":{"type":"string"}},"type":"object"},"httpapi.RotateClientRequest":{"properties":{"precondition_token":{"description":"PreconditionToken is the token of GET /connect/{client}/preview; it\nbinds a supported client's rotation to what the preview showed.","type":"string"}},"type":"object"},"httpapi.RotateClientResponse":{"properties":{"client":{"$ref":"#/components/schemas/httpapi.clientPresence"},"connect":{"$ref":"#/components/schemas/connect.ConnectResult"},"credential":{"type":"string"},"rotation":{"$ref":"#/components/schemas/httpapi.RotationState"},"snippet":{"$ref":"#/components/schemas/httpapi.ClientSnippet"}},"type":"object"},"httpapi.RotationState":{"properties":{"state":{"type":"string"}},"type":"object"},"httpapi.SetActiveProfileRequest":{"properties":{"active_profile":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.StaleReviewErrorResponse":{"properties":{"error":{"type":"string"},"error_code":{"type":"string"},"request_id":{"type":"string"},"stale_tools":{"$ref":"#/components/schemas/httpapi.StaleToolsDetail"},"success":{"type":"boolean"}},"type":"object"},"httpapi.StaleToolsDetail":{"properties":{"changed":{"items":{"type":"string"},"type":"array","uniqueItems":false},"missing_blocks":{"items":{"type":"string"},"type":"array","uniqueItems":false},"unreviewed":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.TryProfileRequest":{"properties":{"limit":{"type":"integer"},"profile":{"$ref":"#/components/schemas/config.ProfileConfig"},"query":{"type":"string"}},"type":"object"},"httpapi.UndoConnectRequest":{"properties":{"backup_name":{"description":"BackupName is the bare filename (filepath.Base) of the backup returned as\nbackup_path by the preceding connect — a name, never a path. Undo resolves\nthe full path server-side by joining it with the client's own config\ndirectory, so a client-supplied value can never contribute a directory\ncomponent (traversal is impossible by construction). Empty means the\nconnect created the file (no prior file existed), so undo removes it.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.UpdateFailureRequest":{"properties":{"stage":{"description":"Stage is the failure stage of the update session.","enum":["appcast","download","install","other"],"type":"string"}},"type":"object"},"httpapi.UpgradeAdminKeyHoldersRequest":{"properties":{"apply":{"type":"boolean"},"mode":{"type":"string"},"precondition_token":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.approveToolsRequest":{"properties":{"approve_all":{"type":"boolean"},"expected_hashes":{"additionalProperties":{"type":"string"},"description":"ExpectedHashes binds the approval to the reviewed definitions (Spec\n117 PR-2, #1566): tool name -\u003e the review's current_hash. Absent or\nnull is an unbound (legacy) request; {} is bound.","type":["object","null"]},"expected_incarnation":{"description":"ExpectedIncarnation binds the approval to the server the client\nreviewed (Spec 116 FR-017); 409 server_replaced on a mismatch. It is\nindependent of ExpectedHashes and the two may be sent together.","type":"integer"},"tools":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.attentionResponse":{"properties":{"count":{"type":"integer"},"generated_at":{"type":"string"},"items":{"items":{"$ref":"#/components/schemas/contracts.AttentionItem"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.bundleRollbackRequest":{"properties":{"digest":{"description":"Digest is the full 64-character lowercase hex SHA-256 of the target.","type":"string"},"embedded":{"description":"Embedded rolls back to the corpus compiled into this binary.","type":"boolean"},"path":{"description":"Path is where to find the target bundle (a hint only).","type":"string"}},"type":"object"},"httpapi.clientPresence":{"properties":{"active_sessions":{"type":"integer"},"blocked_24h":{"type":"integer"},"calls_24h":{"type":"integer"},"config_path":{"type":"string"},"connected":{"type":"boolean"},"connection_unverified":{"type":"boolean"},"credential_checked_at":{"type":"string"},"credential_state":{"$ref":"#/components/schemas/profile.CredentialState"},"display_name":{"type":"string"},"display_path":{"type":"string"},"expires_at":{"type":"string"},"icon":{"type":"string"},"id":{"type":"string"},"installed":{"type":"boolean"},"issuer":{"$ref":"#/components/schemas/auth.CredentialIssuer"},"kind":{"type":"string"},"last_seen":{"type":"string"},"lease":{"type":"boolean"},"profile":{"type":"string"},"profile_missing":{"type":"boolean"},"profile_mode":{"type":"string"},"profile_source":{"description":"profile_source is pin (locked) or binding (switchable) and is empty when\ncredential_state is not client.","type":"string"},"profile_state":{"type":"string"},"profile_title":{"type":"string"},"purpose":{"type":"string"},"reload_hint":{"type":"string"},"revoked_at":{"description":"Spec 115 lifecycle fields of the client's credential record (additive):\nwhen it was revoked, who issued it, its stated (unenforced) purpose,\nwhether it is a task lease (lifetime at issue \u003c= 24h) and whether its\nprofile still exists (ok | dangling | none).","type":"string"},"rotation_pending":{"type":"boolean"},"sessions":{"items":{"$ref":"#/components/schemas/httpapi.clientSession"},"type":"array","uniqueItems":false},"state":{"type":"string"},"token_name":{"type":"string"}},"type":"object"},"httpapi.clientSession":{"properties":{"id":{"type":"string"},"last_activity":{"type":"string"},"profile":{"description":"Profile and ProfileSource are the session's latest effective resolution\n(Spec 108-e FR-033); empty on legacy sessions.","type":"string"},"profile_source":{"type":"string"},"started_at":{"type":"string"},"work_session_id":{"type":"string"}},"type":"object"},"httpapi.clientsResponse":{"properties":{"clients":{"items":{"$ref":"#/components/schemas/httpapi.clientPresence"},"type":"array","uniqueItems":false},"routing":{},"warnings":{"items":{"$ref":"#/components/schemas/httpapi.ClientWarning"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.expectedIncarnationBody":{"properties":{"expected_incarnation":{"type":"integer"}},"type":"object"},"httpapi.securityApproveRequest":{"properties":{"block":{"items":{"type":"string"},"type":"array","uniqueItems":false},"expected_hashes":{"additionalProperties":{"type":"string"},"description":"ExpectedHashes binds the approval to the reviewed definitions (Spec\n117 PR-2, #1566): tool name -\u003e the review's current_hash. Absent or\nnull is an unbound (legacy) request; {} binds to the empty snapshot.\nIndependent of ExpectedIncarnation: a request may carry both, and the\ntwo refusals stay distinct (review_out_of_date vs server_replaced).","type":["object","null"]},"expected_incarnation":{"description":"ExpectedIncarnation binds the approval to the server the client\nreviewed (Spec 116 FR-017): a remove and re-add since then is refused\nwith 409 server_replaced and the replacement stays quarantined.","type":"integer"},"force":{"type":"boolean"}},"type":"object"},"management.BulkOperationResult":{"properties":{"errors":{"additionalProperties":{"type":"string"},"description":"Map of server name to error message","type":"object"},"failed":{"description":"Number of failed operations","type":"integer"},"successful":{"description":"Number of successful operations","type":"integer"},"total":{"description":"Total servers processed","type":"integer"}},"type":"object"},"observability.HealthResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"observability.HealthStatus":{"properties":{"error":{"type":"string"},"latency":{"type":"string"},"name":{"type":"string"},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"}},"type":"object"},"observability.ReadinessResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"ready\" or \"not_ready\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"profile.AccessStepStatus":{"type":"string","x-enum-varnames":["AccessStepPass","AccessStepFail","AccessStepSkip"]},"profile.AccessSubjectKind":{"type":"string","x-enum-varnames":["AccessSubjectClient","AccessSubjectProfile","AccessSubjectToken","AccessSubjectAnonymous"]},"profile.CredentialState":{"description":"Spec 108-f ClientView additions (data-model §7). The Spec 109 fields above\nare unchanged; everything below is additive.\n\ncredential_state is what the client's connection carries (client |\nadmin_key | none | revoked | expired | unknown). The stat-only list never\nreads a config: it reports the token store, else the last on-demand\nobservation (credential_checked_at says when), else unknown.","type":"string","x-enum-varnames":["CredentialStateClient","CredentialStateAdminKey","CredentialStateNone","CredentialStateRevoked","CredentialStateExpired","CredentialStateUnknown"]},"profile.ExplainStep":{"type":"string","x-enum-varnames":["StepCredential","StepProfile","StepServerInScope","StepToolRule","StepTierCap","StepTokenPermission","StepGlobalGate","StepServerState","StepToolApproval"]},"profile.ExplainVerdict":{"type":"string","x-enum-varnames":["ExplainVerdictAllowed","ExplainVerdictBlocked","ExplainVerdictHidden"]},"profile.FixAction":{"type":"string","x-enum-varnames":["FixAllowInProfile","FixClassifyInProfile","FixAddServerToProfile","FixMoveClient","FixEditToken","FixEnableServer","FixApproveTool","FixChangeSetting","FixReconnectClient"]},"profile.WarningCode":{"type":"string","x-enum-varnames":["WarningAnonymousDeniedByBindingGuard","WarningClientHoldsAdminKey","WarningClientCredentialExpiring","WarningClientRotationPending","WarningProfileMissing","WarningClientTokenNameConflict"]},"profile.WarningSeverity":{"type":"string","x-enum-varnames":["WarningSeverityWarn","WarningSeverityInfo"]},"runtime.BindingRef":{"properties":{"client_id":{"type":"string"},"mode":{"type":"string"},"profile":{"type":"string"},"token_name":{"type":"string"}},"type":"object"},"runtime.EffectiveCounts":{"properties":{"by_reason":{"additionalProperties":{"type":"integer"},"type":"object"},"callable":{"type":"integer"},"hidden":{"type":"integer"},"visible":{"type":"integer"}},"type":"object"},"runtime.EffectiveTool":{"properties":{"access":{"$ref":"#/components/schemas/runtime.ToolAccessView"},"classification_stale":{"type":"boolean"},"intrinsic_tier":{"type":"string"},"profile_tier":{"type":"string"},"server":{"type":"string"},"tool":{"type":"string"}},"type":"object"},"runtime.ExplainProfileView":{"properties":{"name":{"type":"string"},"source":{"type":"string"}},"type":"object"},"runtime.ExplainStepView":{"properties":{"detail":{"type":"string"},"status":{"$ref":"#/components/schemas/profile.AccessStepStatus"},"step":{"$ref":"#/components/schemas/profile.ExplainStep"}},"type":"object"},"runtime.ExplainSubjectView":{"properties":{"kind":{"$ref":"#/components/schemas/profile.AccessSubjectKind"},"name":{"type":"string"}},"type":"object"},"runtime.Fix":{"properties":{"action":{"$ref":"#/components/schemas/profile.FixAction"},"label":{"type":"string"},"profile":{"description":"Profile is set for move_client only: the destination profile slug the fix\nmoves the client to. Target stays the client id (UIs navigate by it) and\nLabel carries the title, so neither can name the slug.","type":"string"},"step":{"type":"string","x-enum-varnames":["StepCredential","StepProfile","StepServerInScope","StepToolRule","StepTierCap","StepTokenPermission","StepGlobalGate","StepServerState","StepToolApproval"]},"target":{"type":"string"}},"type":"object"},"runtime.GuardFix":{"properties":{"kind":{"type":"string"},"target":{"type":"string"}},"type":"object"},"runtime.MovedRefs":{"properties":{"clients":{"items":{"type":"string"},"type":"array","uniqueItems":false},"tokens":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"runtime.ProfileView":{"properties":{"blocked_24h":{"type":"integer"},"calls_24h":{"type":"integer"},"code_execution":{"type":"boolean"},"description":{"type":"string"},"effective_code_execution":{"type":"boolean"},"effective_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"effective_unannotated":{"type":"string"},"is_legacy":{"type":"boolean"},"management_tools":{"type":"boolean"},"max_tier":{"type":"string"},"name":{"type":"string"},"servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"switchable_to":{"items":{"type":"string"},"type":"array","uniqueItems":false},"title":{"type":"string"},"tool_count":{"description":"ToolCount is the v2 field: indexed tools on the effective servers.\nDeprecated: use tool_counts.","type":"integer"},"tool_counts":{"$ref":"#/components/schemas/runtime.ToolCounts"},"tools":{"$ref":"#/components/schemas/config.ProfileToolRules"},"unannotated":{"type":"string"},"used_by":{"$ref":"#/components/schemas/runtime.UsedBy"}},"type":"object"},"runtime.ToolAccessView":{"properties":{"callable":{"type":"boolean"},"reason":{"type":"string"},"visible":{"type":"boolean"}},"type":"object"},"runtime.ToolCounts":{"properties":{"destructive":{"type":"integer"},"read":{"type":"integer"},"unannotated_hidden":{"type":"integer"},"write":{"type":"integer"}},"type":"object"},"runtime.TryHidden":{"properties":{"reason":{"type":"string"},"server":{"type":"string"},"tool":{"type":"string"}},"type":"object"},"runtime.UsedBy":{"properties":{"anonymous_profile":{"type":"boolean"},"clients":{"items":{"$ref":"#/components/schemas/runtime.UsedByClient"},"type":"array","uniqueItems":false},"tokens":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"runtime.UsedByClient":{"properties":{"id":{"type":"string"},"mode":{"type":"string"}},"type":"object"},"runtime.WarningAction":{"properties":{"kind":{"type":"string"},"target":{"type":"string"}},"type":"object"},"scanner.BundleRejection":{"properties":{"at":{"type":"string"},"detail":{"description":"Detail is a short human string (which ceiling, which key id, which\nepoch, the bundlesig contract code). Never parsed.","type":"string"},"reason":{"description":"Reason is one of the Reject* constants; the only field to branch on.","type":"string"}},"type":"object"},"secureenv.EnvConfig":{"description":"Environment configuration for secure variable filtering","properties":{"allowed_system_vars":{"items":{"type":"string"},"type":"array","uniqueItems":false},"custom_vars":{"additionalProperties":{"type":"string"},"type":"object"},"enhance_path":{"description":"Enable PATH enhancement for Launchd scenarios","type":"boolean"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned upstream servers (MCP-2769). It is OFF by\ndefault and deliberately kept out of the AllowedSystemVars default list:\nproxy URLs frequently carry credentials (http://user:pass@proxy), so\nforwarding them to every stdio upstream is a credential-leak risk. When\nenabled, values are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"inherit_system_safe":{"type":"boolean"}},"type":"object"},"storage.ClientCredentialObservation":{"properties":{"at":{"type":"string"},"state":{"type":"string"}},"type":"object"},"storage.OnboardingState":{"description":"State is the persisted wizard engagement record. Engaged is true once\nthe wizard was shown and the user completed or skipped it.","properties":{"client_connected_at":{"additionalProperties":{"type":"string"},"description":"ClientConnectedAt records, per client id from the fixed connect client\nregistry (internal/connect.GetAllClients — never user input), the last\ntime a connect write succeeded for that client (Spec 109-b FR-042).\nWritten by the connect success path through UpdateOnboardingState so a\nconcurrent onboarding/mark write can never drop it. Consumed by the\nVerify step / presence layer to tell \"connected, never seen\" apart from\n\"connected seconds ago, hasn't reconnected yet\".","type":"object"},"client_credential_observed":{"additionalProperties":{"$ref":"#/components/schemas/storage.ClientCredentialObservation"},"description":"ClientCredentialObserved records, per client id, the LAST credential\nclassification an on-demand read produced (Spec 108-f FR-025, F11): what\nthe client's config held (client | admin_key | none | revoked | expired)\nand when it was read. The stat-only GET /clients listing never reads a\nconfig (Spec 075: no macOS App-Data prompt from a list), so this is how\nit still reports a client that holds the admin key across restarts. It is\nwritten only when the classification CHANGES, by every on-demand read\n(GET /clients/{id}, GET /connect/{client}, the admin-key upgrade preview,\na connect write) and deleted on disconnect. Additive: an older binary\nignores it, and a record without it reads as \"unknown\".","type":"object"},"client_disconnected_at":{"additionalProperties":{"type":"string"},"type":"object"},"client_last_seen":{"additionalProperties":{"type":"string"},"description":"ClientLastSeen records the latest MCP initialize for a recognised client\nalias. It is intentionally kept with onboarding state: presence is a\nlocal UI concern and must still work when telemetry is disabled.","type":"object"},"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"completed_external\",\n\"skipped\" (Spec 080 FR-001). \"completed_external\" records a dismissal\nwhere the connect step was untouched but the install was already\nconnected outside the wizard (CLI, ConnectModal, manual config).","type":"string"},"engaged":{"description":"Engaged is true once the wizard was shown and the user completed or\nskipped it. Once true, the wizard does not auto-show again, even if\nstate regresses (e.g. user disconnects all clients).","type":"boolean"},"engaged_at":{"description":"EngagedAt is the timestamp of completion or explicit skip.","type":"string"},"first_shown_at":{"description":"FirstShownAt is the timestamp of first wizard render.","type":"string"},"server_step_status":{"description":"ServerStepStatus is one of: \"\", \"completed\", \"skipped\".","type":"string"}},"type":"object"},"telemetry.FeedbackContext":{"properties":{"arch":{"type":"string"},"connected_server_count":{"type":"integer"},"edition":{"type":"string"},"os":{"type":"string"},"routing_mode":{"type":"string"},"server_count":{"type":"integer"},"version":{"type":"string"}},"type":"object"},"telemetry.FeedbackRequest":{"properties":{"category":{"description":"bug, feature, other","type":"string"},"context":{"$ref":"#/components/schemas/telemetry.FeedbackContext"},"email":{"type":"string"},"message":{"type":"string"}},"type":"object"},"telemetry.FeedbackResponse":{"properties":{"error":{"type":"string"},"issue_url":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}},"securitySchemes":{"ApiKeyAuth":{"description":"API key authentication via query parameter. Use ?apikey=your-key","in":"query","name":"apikey","type":"apiKey"}}}, "info": {"contact":{"name":"MCPProxy Support","url":"https://github.com/smart-mcp-proxy/mcpproxy-go"},"description":"{{escape .Description}}","license":{"name":"MIT","url":"https://opensource.org/licenses/MIT"},"title":"{{.Title}}","version":"{{.Version}}"}, "externalDocs": {"description":"","url":""}, - "paths": {"/api/v1/access/explain":{"get":{"description":"For one subject (exactly one of client=\u003cid\u003e, token=\u003cname\u003e, profile=\u003cname\u003e or anonymous=true) and one upstream tool (server:tool), returns the ordered chain of gates a real call would meet - credential, profile, server_in_scope, tool_rule, tier_cap, token_permission, global_gate, server_state, tool_approval - the overall verdict (allowed, blocked, hidden), the first failing step and the fixes for it in preference order. The chain is the one every discovery and dispatch path walks, so ` + "`" + `allowed` + "`" + ` equals what a real call does. Built-in management tools are not explained (400). A client credential is explained with client=, never token=. Administrators only.","parameters":[{"description":"Upstream tool as server:tool","in":"query","name":"tool","required":true,"schema":{"type":"string"}},{"description":"Client id","in":"query","name":"client","schema":{"type":"string"}},{"description":"Regular agent token name","in":"query","name":"token","schema":{"type":"string"}},{"description":"Profile name","in":"query","name":"profile","schema":{"type":"string"}},{"description":"Explain an anonymous (credential-less) caller","in":"query","name":"anonymous","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"The explanation"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Not exactly one subject, a built-in tool, or token=client-\u003cid\u003e"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"client / token / profile not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Explain why a tool is or is not reachable","tags":["access"]}},"/api/v1/activity":{"get":{"description":"Returns paginated list of activity records with optional filtering","parameters":[{"description":"Filter by activity type(s), comma-separated for multiple (Spec 024)","in":"query","name":"type","schema":{"enum":["tool_call","policy_decision","quarantine_change","server_change","system_start","system_stop","internal_tool_call","config_change","preflight","prompt_get","profile_change"],"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Filter by parent call id — returns the sub-calls one code_execution issued","in":"query","name":"parent_id","schema":{"type":"string"}},{"description":"Filter by call-error class (Spec 113-c)","in":"query","name":"error_class","schema":{"enum":["network","timeout","http","jsonrpc","tool_error","session_terminated","auth","proxy_policy","proxy_internal","cancelled"],"type":"string"}},{"description":"Filter by fault domain (Spec 113-c)","in":"query","name":"fault_domain","schema":{"enum":["upstream","proxy","client"],"type":"string"}},{"description":"Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)","in":"query","name":"include_call_tool","schema":{"type":"boolean"}},{"description":"Filter by sensitive data detection (true=has detections, false=no detections)","in":"query","name":"sensitive_data","schema":{"type":"boolean"}},{"description":"Filter by specific detection type (e.g., 'aws_access_key', 'credit_card')","in":"query","name":"detection_type","schema":{"type":"string"}},{"description":"Filter by severity level","in":"query","name":"severity","schema":{"enum":["critical","high","medium","low"],"type":"string"}},{"description":"Alias of token (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by the token in effect for the call; - selects records with no token (Spec 108)","in":"query","name":"token","schema":{"type":"string"}},{"description":"Filter by the profile in effect for the call; - selects records with no profile (Spec 108)","in":"query","name":"profile","schema":{"type":"string"}},{"description":"Filter by client id; - selects records with no client (Spec 108)","in":"query","name":"client","schema":{"type":"string"}},{"description":"Filter by the client's self-reported name (advisory; Spec 108)","in":"query","name":"client_name","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Omit arguments, response and metadata except a contextual whitelist (intent.reason, intent.operation_type, decision, reason, client_name, client_version) (default: false). For clients that render summary fields only; has_sensitive_data is still derived before metadata is dropped.","in":"query","name":"exclude_payloads","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"List activity records","tags":["Activity"]}},"/api/v1/activity/export":{"get":{"description":"Exports activity records in JSON Lines or CSV format for compliance","parameters":[{"description":"Export format: json (default) or csv","in":"query","name":"format","schema":{"type":"string"}},{"description":"Filter by activity type","in":"query","name":"type","schema":{"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Filter by parent call id — exports the sub-calls one code_execution issued","in":"query","name":"parent_id","schema":{"type":"string"}},{"description":"Filter by call-error class (Spec 113-c)","in":"query","name":"error_class","schema":{"enum":["network","timeout","http","jsonrpc","tool_error","session_terminated","auth","proxy_policy","proxy_internal","cancelled"],"type":"string"}},{"description":"Filter by fault domain (Spec 113-c)","in":"query","name":"fault_domain","schema":{"enum":["upstream","proxy","client"],"type":"string"}},{"description":"Alias of token (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by the token in effect for the call; - selects records with no token (Spec 108)","in":"query","name":"token","schema":{"type":"string"}},{"description":"Filter by the profile in effect for the call; - selects records with no profile (Spec 108)","in":"query","name":"profile","schema":{"type":"string"}},{"description":"Filter by client id; - selects records with no client (Spec 108)","in":"query","name":"client","schema":{"type":"string"}},{"description":"Filter by the client's self-reported name (advisory; Spec 108)","in":"query","name":"client_name","schema":{"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to export (1-50000, default 10000)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"application/x-ndjson":{"schema":{"type":"string"}},"text/csv":{"schema":{"type":"string"}}},"description":"Streamed activity records"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Export activity records","tags":["Activity"]}},"/api/v1/activity/summary":{"get":{"description":"Returns aggregated activity statistics for a time period","parameters":[{"description":"Time period: 1h, 24h (default), 7d, 30d","in":"query","name":"period","schema":{"type":"string"}},{"description":"Group by: server, tool (optional)","in":"query","name":"group_by","schema":{"type":"string"}},{"description":"Alias of token (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Count only records made under this token; - selects records with no token (Spec 108)","in":"query","name":"token","schema":{"type":"string"}},{"description":"Count only records made under this profile; - selects records with no profile (Spec 108)","in":"query","name":"profile","schema":{"type":"string"}},{"description":"Count only records made by this client id; - selects records with no client (Spec 108)","in":"query","name":"client","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity summary statistics","tags":["Activity"]}},"/api/v1/activity/usage":{"get":{"description":"Returns the actor-owned usage aggregate (per-tool rollup + timeline + tokens-saved headline) for the Web UI usage graphs (Spec 069). Served from an in-memory snapshot — never a per-request full-log scan. Per-tool metrics are lifetime-cumulative; ` + "`" + `window` + "`" + ` scopes the timeline and filters the tool list to tools active within the span.","parameters":[{"description":"Time window for timeline + tool-list membership","in":"query","name":"window","schema":{"enum":["24h","7d","all"],"type":"string"}},{"description":"Filter to one server","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter to one tool","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter to tools with activity of this status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Top-N tools by sort key; remainder folded into 'other' (default 20)","in":"query","name":"top","schema":{"type":"integer"}},{"description":"Ranking key for the per-tool list","in":"query","name":"sort","schema":{"enum":["calls","resp_bytes","error_rate","p95"],"type":"string"}},{"description":"Alias of token (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Restrict to calls made under this token; - selects records with no token. Computed from a window-bounded scan (Spec 108)","in":"query","name":"token","schema":{"type":"string"}},{"description":"Restrict to calls made under this profile; - selects records with no profile. Computed from a window-bounded scan (Spec 108)","in":"query","name":"profile","schema":{"type":"string"}},{"description":"Restrict to calls made by this client id; - selects records with no client. Computed from a window-bounded scan (Spec 108)","in":"query","name":"client","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get usage statistics aggregate","tags":["Activity"]}},"/api/v1/activity/{id}":{"get":{"description":"Returns full details for a single activity record","parameters":[{"description":"Activity record ID (ULID)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity record details","tags":["Activity"]}},"/api/v1/annotations/coverage":{"get":{"description":"Reports how many upstream tools have MCP annotations vs don't, broken down by server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Annotation coverage report"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get annotation coverage report","tags":["annotations"]}},"/api/v1/attention":{"get":{"description":"One list, one count, every surface (Web UI, macOS tray/Home, CLI) reads from it. Filtered per caller: an administrator sees every item; a scoped caller (agent token, or a non-admin server-edition OAuth user session) sees only server items whose server it may enumerate; every other subject type (client, setting) is administrator-only.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.attentionResponse"}}},"description":"The needs-attention list"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the needs-attention list","tags":["attention"]}},"/api/v1/catalog/search":{"get":{"description":"Fans out to every enabled catalog source (registry) in parallel, merges, de-duplicates and ranks the results (FR-060/061). An empty q returns official + popular sections instead of a flat list. Open to any authenticated caller — added is the only field filtered per caller scope (FR-007).","parameters":[{"description":"Free-text search","in":"query","name":"q","schema":{"type":"string"}},{"description":"Narrow to one catalog source id","in":"query","name":"source","schema":{"type":"string"}},{"description":"Not supported: catalog entries carry no tags; a non-empty value returns 400","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Max results (default 20, max 50)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"tag filtering is not supported"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"configured servers could not be listed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search the server catalog across every enabled source","tags":["catalog"]}},"/api/v1/clients":{"get":{"description":"Lists every client row (supported, other and custom) with its presence, its credential and its profile binding, plus response-level warnings. The list is content-read-free (Spec 075): credential_state comes from the token store, else the last on-demand observation (credential_checked_at), else unknown. It runs only the time-based half of the rotation reconciler. The profile and client filters match the CURRENT binding and are applied AFTER warnings are computed over the full set.","parameters":[{"description":"Rows whose active credential is bound to this profile; - = bound to All servers","in":"query","name":"profile","schema":{"type":"string"}},{"description":"Exact client id","in":"query","name":"client","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Client rows, routing and warnings"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List clients","tags":["clients"]},"post":{"description":"Creates a per-client credential for a client that is NOT in the connect registry (a script, a CI job, an editor without a connect adapter). The secret (mcp_cli_...) is returned ONCE, with a paste-ready snippet that carries it in the X-API-Key header. The id follows the client-id rule (lower-case letters, digits, '-' or '_', at most 56 characters) and must not be a supported client's id. A named profile binds the credential locked unless mode says otherwise. Refused with 409 binding_bypassable_without_auth when the binding would be bypassable while require_mcp_auth is off (FR-008a), and 409 conflicting_token when a regular token holds client-\u003cid\u003e. Every value (id, display_name, profile, mode, expires_in, purpose) passes the shared secret-shaped input screen first: a credential, the API key or a detector-flagged secret answers 400 secret_in_argument naming the field, and nothing is minted. A malformed body answers a sanitized 400 that never echoes a caller key or value. Writes one profile_change record with change issue (Spec 115) and publishes credentials.changed. Personal edition only.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CreateClientRequest"}}},"description":"id (required), optional display_name, profile, mode, expires_in and purpose","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"The new client row, its credential (once) and a snippet"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.BindingGuardResponse"}}},"description":"binding_bypassable_without_auth, or conflicting_token (ClientCredentialConflictResponse)"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a custom client","tags":["clients"]}},"/api/v1/clients/bulk-assign":{"post":{"description":"Moves every client credential bound to from_profile (\"\" = All servers) onto to_profile (\"\" = All servers). The guard and the credential precondition apply PER CLIENT: a refused client is reported in skipped[] with the code the single operation returns, the others move and each writes its own assign record and emits client.binding_changed. Personal edition only.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.BulkAssignRequest"}}},"description":"from_profile and to_profile (both required; empty = All servers) and optional mode","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Moved client ids and the clients that were skipped"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Move every client of one profile to another","tags":["clients"]}},"/api/v1/clients/upgrade-admin-key-holders":{"post":{"description":"Without apply: classifies every supported client whose config still holds the instance admin API key (an on-demand read per client) and returns preview[] (display path, what would change, the masked credential mcp_cli_••••, the binding and a precondition_token per client), a combined precondition_token, and guard when the whole request would be refused by FR-008a; next_step is rotate_admin_api_key when nothing holds the key. Nothing is written or minted. With apply=true: recomputes the preview, refuses with 409 precondition_failed when a sent precondition_token no longer matches, runs the FR-008a guard over the WHOLE request (409 binding_bypassable_without_auth, nothing minted, no file written), then connects each client with force bound to its own token; one client's failure does not stop the others. Each mint writes its own assign record. The guard only applies when a named profile is given. Personal edition only.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UpgradeAdminKeyHoldersRequest"}}},"description":"Optional profile and mode for every upgraded client; apply and precondition_token"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"UpgradePreview (no apply) or UpgradeResult (apply)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.BindingGuardResponse"}}},"description":"precondition_failed, or binding_bypassable_without_auth"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Upgrade every client that holds the admin API key","tags":["clients"]}},"/api/v1/clients/{client}":{"delete":{"description":"Revokes the client's credential; it stops authenticating at once. With disconnect=true a SUPPORTED client's mcpproxy entry is removed from its config FIRST, but the credential is revoked either way: revocation never waits for a config write, so a failed write (for example a macOS App-Data denial) leaves the client cut off and is reported as disconnected:false with disconnect_error. A client with no credential record is 409 no_client_credential. disconnect=true on a custom client is ignored. Writes one forget record naming the revoked token. The FR-008a guard is not involved: revoking only removes a binding.","parameters":[{"description":"Client id","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Also remove the entry from the client's config (supported clients)","in":"query","name":"disconnect","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Revoked token name and what happened to the config entry"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"no_client_credential"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Forget a client (revoke its credential)","tags":["clients"]},"get":{"description":"Returns one client row with its sessions (newest 20, each with its latest effective profile). This is the only read that may open the client's config file: it runs the full rotation reconciler first, classifies the credential the config holds and records the observation. No scope filter is honoured on this route.","parameters":[{"description":"Client id","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Client row"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"client not found"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get one client","tags":["clients"]}},"/api/v1/clients/{client}/binding":{"put":{"description":"Changes the profile binding (and optionally the locked/switchable mode) of a\nclient that holds an active client credential. One service operation shared by\nevery surface: it updates the credential in the token store, clears the stored\nset_profile selection of every live session of that credential, sends\nnotifications/tools/list_changed to each, emits client.binding_changed and writes\none profile_change activity record. It never touches the client's config file.\nA client without an active client credential (none, admin key, revoked or expired)\nis refused with 409 no_client_credential and nothing is minted or written. A\nreassignment that would leave the binding bypassable while require_mcp_auth is off\nis refused with 409 binding_bypassable_without_auth (FR-008a). Personal edition only.","parameters":[{"description":"Client id (lower-case letters, digits, '-' or '_', at most 56 characters)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.PutClientBindingRequest"}}},"description":"profile (required; empty = All servers) and optional mode (locked|switchable)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"The client row after the change, with its warnings"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.BindingGuardResponse"}}},"description":"no_client_credential, connect_in_progress (a connect of this client is mid-write), or binding_bypassable_without_auth with bindings and fixes"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reassign a client's profile and/or mode","tags":["clients"]}},"/api/v1/clients/{client}/rotate":{"post":{"description":"Replaces a client's secret without ever invalidating the old one mid-flight (FR-021a). A SUPPORTED client is rewritten through connect: staged rotation, binding kept, finalized when the config write succeeds, rolled back (the old secret keeps working) when it fails; a preview's precondition_token binds the call, a mismatch is the connect 409 precondition_failed. A CUSTOM client gets a new secret returned ONCE with a snippet; both secrets authenticate until POST /clients/{client}/rotate/finalize or 24 hours. A client with no active credential is 409 no_client_credential. The FR-008a guard is not involved: a rotation never changes a binding's reach. Writes one rotate record when it finalizes.","parameters":[{"description":"Client id","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.RotateClientRequest"}}},"description":"Optional precondition_token of a connect preview"},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"The client row and the rotation state"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required (or macOS App-Data block)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectConflictResponse"}}},"description":"no_client_credential, precondition_failed, connect_in_progress (another connect of this client is mid-write), or credential_superseded (the written credential was replaced or revoked before it could be finalized; reconnect)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Rotate a client's credential","tags":["clients"]}},"/api/v1/clients/{client}/rotate/finalize":{"post":{"description":"Promotes a client's pending secret: the old secret stops authenticating. Idempotent: with no rotation in progress it is a no-op success. A client with no client credential is 409 no_client_credential. Writes one rotate record when it promotes.","parameters":[{"description":"Client id","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"The client row; rotation.state is finalized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"no_client_credential, or connect_in_progress (a connect of this client is mid-write)"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Finalize a staged rotation","tags":["clients"]}},"/api/v1/code/scripts":{"get":{"description":"List the stored scripts available to the code_execution tool. Scripts are ` + "`" + `\u003cname\u003e.js` + "`" + ` / ` + "`" + `\u003cname\u003e.ts` + "`" + ` files in the ` + "`" + `scripts/` + "`" + ` directory next to the active configuration file. Entries are advisory: ` + "`" + `ok` + "`" + ` scripts are invocable, ` + "`" + `ambiguous` + "`" + ` names have both extensions, and ` + "`" + `invalid` + "`" + ` ones report why (empty, oversized, unreadable, non-regular). Read-only — there is no write surface for stored scripts. Administrator-only (Spec 105 FR-012): an agent token, whatever its server scope, is refused with 403 — the listing is the enumeration the missing-script error withholds from a scoped caller.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Stored scripts and the directory they were read from"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot list stored scripts"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List stored code-execution scripts","tags":["code"]}},"/api/v1/config":{"get":{"description":"Retrieves the current MCPProxy configuration including all server definitions, global settings, and runtime parameters","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetConfigResponse"}}},"description":"Configuration retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read the configuration document"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get current configuration","tags":["config"]},"patch":{"description":"Deep-merges only the fields present in the request body onto the live in-memory configuration and routes the result through the existing apply pipeline (validation, change detection, disk persistence, hot-reload). Fields the client omits — including masked secrets such as ` + "`" + `api_key` + "`" + ` and secret request headers — are preserved verbatim. Nested objects are merged recursively; arrays and scalars replace wholesale.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Partial configuration with only the fields to change","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration patch applied (inspect validation_errors for rejected values)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload or empty patch"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.BindingGuardResponse"}}},"description":"binding_bypassable_without_auth: the patch would let a client bound to a named profile escape it while require_mcp_auth is off (FR-008a); nothing was written"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"telemetry.enabled is locked while an environment variable (DO_NOT_TRACK, CI, MCPPROXY_TELEMETRY=false) disables telemetry; nothing was written (FR-044a)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to read or apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update configuration","tags":["config"]}},"/api/v1/config/apply":{"post":{"description":"Applies a new MCPProxy configuration. Validates and persists the configuration to disk. Some changes apply immediately, while others may require a restart. Returns detailed information about applied changes and restart requirements.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to apply","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration applied successfully with change details"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.BindingGuardResponse"}}},"description":"binding_bypassable_without_auth: the write would let a client bound to a named profile escape it while require_mcp_auth is off (FR-008a); nothing was written"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"telemetry.enabled is locked while an environment variable (DO_NOT_TRACK, CI, MCPPROXY_TELEMETRY=false) disables telemetry; nothing was written (FR-044a)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Apply configuration","tags":["config"]}},"/api/v1/config/docker-isolation":{"patch":{"description":"Convenience endpoint to flip ` + "`" + `docker_isolation.enabled` + "`" + ` without resending the full config. Persists to disk via the existing config writer — the file watcher then hot-reloads the change. Returns the new state and whether a restart is required for existing connections to pick it up.","requestBody":{"content":{"application/json":{"schema":{"properties":{"enabled":{"type":"boolean"}},"type":"object"}}},"description":"New isolation state","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Isolation toggle applied"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.BindingGuardResponse"}}},"description":"binding_bypassable_without_auth (FR-008a); nothing was written"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Toggle global Docker isolation","tags":["config"]}},"/api/v1/config/validate":{"post":{"description":"Validates a provided MCPProxy configuration without applying it. Checks for syntax errors, invalid server definitions, conflicting settings, and other configuration issues.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to validate","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ValidateConfigResponse"}}},"description":"Configuration validation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Validation failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Validate configuration","tags":["config"]}},"/api/v1/connect":{"get":{"description":"Returns the connection status for all known MCP client applications.\nEach entry indicates whether the client config file exists and whether\nMCPProxy is currently registered in it. This stat-only listing never reads\na client config (Spec 075), so a client whose config exists reports\ncredential_state \"unknown\"; GET /connect/{client} resolves it.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"List of ClientStatus objects"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List client connection status","tags":["connect"]}},"/api/v1/connect/{client}":{"delete":{"description":"Remove the MCPProxy entry from the specified client's configuration file.\nCreates a backup of the existing config before modifying.\nAlso revokes the client's credential (it stops authenticating at once); the result names it in credential_revoked, or reports credential_revoke_error when the revoke failed after the entry was removed. Undoing a disconnect does not restore the credential: a later connect mints a new one.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode, zcode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional parameters (server_name)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or entry not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disconnect MCPProxy from a client","tags":["connect"]},"get":{"description":"Resolves one client's status by reading its config file on demand.\nThis is the only Connect endpoint that opens a client config file, so\non macOS it is the sole place an App-Data privacy prompt may legitimately\nappear (scoped to this user action). Resolves access_state to\naccessible|absent|denied|malformed and populates remediation when denied.\nFor a connected client it also resolves credential_state (Spec 108 FR-025):\nclient (an active per-client credential), admin_key (the instance admin API\nkey), none, revoked (a rotated-away or revoked credential) or expired. The\ncredential value is classified and never echoed.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode, zcode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ClientStatus"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get a single client's connection status (on-demand)","tags":["connect"]},"post":{"description":"Register MCPProxy as an MCP server in the specified client's configuration file.\nCreates a backup of the existing config before modifying.\nOptionally accepts precondition_token from a preview (Spec 091): when supplied,\nthe core rechecks the raw pre-write state and the entry it would write, and\nrefuses a drifted write with 409 before taking any backup. The 409 body's\naction discriminates the two conflict kinds: \"precondition_failed\" (stale\npreview — re-preview, do not retry) vs \"already_exists\" (entry present — pass\nforce=true). force=true never rescues a stale token.\n\nSpec 108 FR-024: the entry carries a per-client mcp_cli_ credential bound to the\nrequested profile (default: All servers, switchable; a reconnect keeps the client's\nexisting binding unless a profile is given), never the instance admin API key.\nReconnecting over an active credential is a staged rotation: the old secret keeps\nworking until the config write succeeds. keyless=true writes no credential and is\nonly possible while require_mcp_auth is off (400 otherwise, and with a profile).\nA write that would let the client escape its profile by omitting its credential\nwhile require_mcp_auth is off is refused with 409 binding_bypassable_without_auth\n(nothing minted or written); a token named client-\u003cid\u003e held by a regular agent\ntoken is refused with 409 conflicting_token.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode, zcode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional connection parameters (server_name, force, precondition_token, profile, mode, keyless)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult (credential is the masked mcp_cli_ value, with token_name, profile, mode, keyless and rotation)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Bad request; field names the offending input (profile, mode, keyless)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectConflictResponse"}}},"description":"Conflict: action=already_exists (use force=true) or action=precondition_failed (preview is stale; re-preview); or binding_bypassable_without_auth (BindingGuardResponse); or conflicting_token (ClientCredentialConflictResponse); or connect_in_progress (another connect of this client is mid-write) or credential_superseded (the written credential was replaced or revoked before it could be finalized; reconnect)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable (or no credential store wired while require_mcp_auth is on)"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Connect MCPProxy to a client","tags":["connect"]}},"/api/v1/connect/{client}/preview":{"get":{"description":"Returns the exact entry a subsequent connect would add to the client's\nconfig — target path, server key, entry name, and entry contents — WITHOUT\nmodifying the file or creating a backup (Spec 078 US1). The per-client credential\nis masked in the payload (credential, always starting mcp_cli_; contains_api_key is\nalways false since connect never writes the admin API key); profile, mode and\nkeyless echo the requested intent.\nentry_exists distinguishes a create from an overwrite of a same-named entry.\nReads the config on demand to classify create-vs-overwrite, so on macOS this\nmay raise an App-Data privacy prompt; a denial returns 403 + remediation.\nSpec 091 adds three fields: existing_entry_summary (present only when\nentry_exists — a sanitized, non-secret projection of the entry being replaced:\nits name, type, endpoint with query/userinfo stripped, command, and header and\nenv NAMES, never values); precondition_token (always present — an opaque keyed\ndigest of the raw pre-write state and the pending entry, echoed back on POST\nconnect to detect drift); and connect_refusal (present when the write would\nrefuse regardless of intent, e.g. a non-create-capable client with no config —\ntreat its presence as \"Connect unavailable\").","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode, zcode)","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Entry name to preview (defaults to mcpproxy); mirror the value passed to POST connect","in":"query","name":"server_name","schema":{"type":"string"}},{"description":"Profile the client credential would bind to (empty = All servers); mirror POST connect","in":"query","name":"profile","schema":{"type":"string"}},{"description":"locked|switchable; mirror POST connect","in":"query","name":"mode","schema":{"enum":["locked","switchable"],"type":"string"}},{"description":"Preview a credential-less entry (only while require_mcp_auth is off)","in":"query","name":"keyless","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectPreview"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid intent (field names the input; keyless with auth on or with a profile)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required or access denied by macOS App Data"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview the change a connect would make (no write)","tags":["connect"]}},"/api/v1/connect/{client}/undo":{"post":{"description":"Reverts the connect that produced the named backup (Spec 078 US3):\nrestores the client config byte-for-byte from that backup, or — when\nbackup_name is empty because the connect created the file — deletes the\ncreated file. backup_name is the bare filename of the backup the connect\nreturned (never a path); undo resolves the full path server-side inside\nthe client's own config directory, so a client value cannot escape it.\nRefuses with 409 when the config changed since the connect (undo never\nclobbers later edits; use DELETE /connect/{client} for a surgical entry\nremoval instead). Takes its own safety backup first; its path is returned\nas backup_path in the result. Undo is \"as if the connect never happened\": a\nclient credential the connect minted or rotated is revoked unless the restored\nconfig still holds it, and the result names it in credential_revoked.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode, zcode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UndoConnectRequest"}}},"description":"Undo parameters (server_name, backup_name = the bare filename of the backup the preceding connect returned)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult (action restored|deleted)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (e.g. backup_name is a path, or not a backup of this client's config)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or backup no longer exists"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Config changed since connect; undo refused"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Undo a connect, restoring the pre-connect config","tags":["connect"]}},"/api/v1/diagnostics":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/docker/status":{"get":{"description":"Retrieve current Docker availability and recovery status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Docker status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get Docker status","tags":["docker"]}},"/api/v1/doctor":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/feedback":{"post":{"description":"Submit a bug report, feature request, or general feedback","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackRequest"}}},"description":"Feedback request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Bad Request"},"429":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyAuth":[]}],"summary":"Submit feedback","tags":["feedback"]}},"/api/v1/index/search":{"get":{"description":"Search across all upstream MCP server tools using BM25 keyword search","parameters":[{"description":"Search query","in":"query","name":"q","required":true,"schema":{"type":"string"}},{"description":"Maximum number of results","in":"query","name":"limit","schema":{"default":10,"maximum":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchToolsResponse"}}},"description":"Search results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing query parameter)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search for tools","tags":["tools"]}},"/api/v1/info":{"get":{"description":"Get essential server metadata including version, web UI URL, endpoint addresses, and update availability\nweb_ui_url carries the ?apikey= credential ONLY for an authenticated admin; a scoped agent token receives the bare URL\nThis endpoint is designed for tray-core communication and version checking\nUse refresh=true query parameter to force an immediate update check against GitHub\nThe launched_by field reports durable launch provenance (\"tray\", \"installer\", or \"\" for user-launched/unknown)","parameters":[{"description":"Force immediate update check against GitHub","in":"query","name":"refresh","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Server information with optional update info"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server information","tags":["status"]}},"/api/v1/onboarding/mark":{"post":{"description":"Updates wizard engagement and per-step status. Once engaged is\ntrue, the wizard does not auto-show again, even if state regresses.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.OnboardingMarkRequest"}}},"description":"Mark request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Updated OnboardingStateResponse"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read onboarding state"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Mark onboarding wizard state (Spec 046)","tags":["onboarding"]}},"/api/v1/onboarding/state":{"get":{"description":"Returns the wizard engagement record alongside live predicates\n(whether any client is connected, whether any server is configured),\nplus a derived ShouldShowWizard flag the frontend can rely on.","responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OnboardingStateResponse"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read onboarding state"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get onboarding wizard state and predicates (Spec 046)","tags":["onboarding"]}},"/api/v1/preflight":{"post":{"description":"Deterministic, side-effect-free availability check for a caller-supplied list of tool IDs (Spec 098). Performs zero upstream calls and mutates no runtime state. HTTP status reports whether the CHECK executed: a fully blocked set is still 200, with the availability verdict in the body. Every executed preflight writes an activity record before the response is returned.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.PreflightRequest"}}},"description":"Tool IDs (1-100 before dedup), optional profile, annotation policy filters and wait budget","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Preflight verdict and per-tool results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Validation error (malformed, oversized, doubled or unknown-field body; empty or oversized tool list; conflicting duplicate pins; unknown profile; wait_ms out of range)"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Missing or invalid credentials"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Runtime unavailable, evaluator infrastructure read failure, or the activity record could not be persisted"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Preflight required tools","tags":["tools"]}},"/api/v1/profiles":{"get":{"description":"Lists every profile as a ProfileView: the config fields as stored, the derived effective values, visible tool counts by tier, 24 h calls and blocked calls, and (administrators only) used_by and the anonymous_profile. A caller that is not an administrator sees only the profiles its entitlement reaches, with servers, rules and switchable_to narrowed to what it may see; an unreachable profile is omitted, never shown empty.","responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Profile list"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Configuration unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List configured profiles","tags":["profiles"]},"post":{"description":"Adds a profile. The body is a ProfileConfig. A name that already exists is 409 profile_exists; ` + "`" + `active` + "`" + ` and ` + "`" + `try` + "`" + ` are reserved by the REST API; a fatal validation rule is 400 with the offending field and the unchanged FR-007 text. A write that would let a client bound to a named profile escape it while require_mcp_auth is off is refused 409 binding_bypassable_without_auth (FR-008a) and nothing is written. Writes one profile_change record.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.ProfileConfig"}}},"description":"The profile","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Created, with the validator's warnings"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ProfileConflictResponse"}}},"description":"profile_exists, or binding_bypassable_without_auth (BindingGuardResponse)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Create a profile","tags":["profiles"]}},"/api/v1/profiles/active":{"get":{"description":"Deprecated (Spec 108 FR-039; sends a Deprecation header, successor GET /api/v1/profiles). Get the server-level default active profile used by UI surfaces (Web UI / tray). Empty string means \"all servers\". Note: within a live MCP session, the set_profile tool selection takes precedence over this default.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the default active profile","tags":["profiles"]},"put":{"description":"Deprecated (Spec 108 FR-039; sends a Deprecation header, successor GET /api/v1/profiles). Set the server-level default active profile for UI surfaces. The slug must match a configured profile; pass an empty string to clear. This does not affect live MCP sessions, which use the set_profile tool.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.SetActiveProfileRequest"}}},"description":"Profile slug to activate (empty clears)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid request body"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot change the active profile)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Set the default active profile","tags":["profiles"]}},"/api/v1/profiles/try":{"post":{"description":"Evaluates a DRAFT profile against a query exactly as retrieve_tools would (policy is applied before the limit) and returns the hits plus what the draft hides (hidden_by_profile and up to 100 {server, tool, reason}). The draft is validated as if it replaced the same-named profile, or was appended under a placeholder name when it has none. Nothing is persisted, no record is written, no event is published and the FR-008a guard does not run. Administrators only.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.TryProfileRequest"}}},"description":"draft profile, query and optional limit (default 10, max 50)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Search result under the draft"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid draft; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Try a draft profile","tags":["profiles"]}},"/api/v1/profiles/{name}":{"delete":{"description":"Deletes a profile. 409 profile_in_use (with used_by) while clients or tokens point at it, unless reassign_to names another existing profile (every pin moves there) or force is set (pins are left dangling, which the resolver treats as deny-all). 409 profile_is_anonymous_profile whenever it is the anonymous_profile and reassign_to is absent, even with force. Both paths remove the name from every switchable_to. Guarded by FR-008a. Writes one ` + "`" + `delete` + "`" + ` record.","parameters":[{"description":"Profile name","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Another existing profile that takes over the pins","in":"query","name":"reassign_to","schema":{"type":"string"}},{"description":"Delete although in use, leaving pins dangling","in":"query","name":"force","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Deleted"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"reassign_to does not name another existing profile"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"profile not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ProfileConflictResponse"}}},"description":"profile_in_use, profile_is_anonymous_profile, or binding_bypassable_without_auth (BindingGuardResponse)"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a profile","tags":["profiles"]},"get":{"description":"Returns the ProfileView of the named profile. A caller that is not an administrator gets the same 404 for an unreachable profile as for an unknown one.","parameters":[{"description":"Profile name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"The profile"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"profile not found"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get one profile","tags":["profiles"]},"put":{"description":"Replaces the named profile with the body (a ProfileConfig whose name, when sent non-empty, must equal the path; 409 name_mismatch otherwise - a rename has its own route; an omitted or empty name takes the path's). A write whose only change is tools.classify is recorded as ` + "`" + `classify` + "`" + `. Guarded by FR-008a like a create.","parameters":[{"description":"Profile name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.ProfileConfig"}}},"description":"The profile","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Updated, with the validator's warnings"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"profile not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ProfileConflictResponse"}}},"description":"name_mismatch, or binding_bypassable_without_auth (BindingGuardResponse)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replace a profile","tags":["profiles"]}},"/api/v1/profiles/{name}/effective-tools":{"get":{"description":"One row per catalog tool with the verdict of the access chain (the same rendering GET /tools?profile= uses): server, tool, intrinsic_tier, profile_tier, access{visible, callable, reason} and classification_stale. With client=\u003cid\u003e the client's credential is evaluated UNDER this profile (\"what would Cursor get here\"). An administrator gets every row plus counts.callable, counts.by_reason and stale_classifications; every other caller gets only visible rows and counts{visible, hidden}, and client= and reason= are refused 403. An unreachable profile answers exactly like an unknown one.","parameters":[{"description":"Profile name","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Evaluate this client's credential under the profile (administrators only)","in":"query","name":"client","schema":{"type":"string"}},{"description":"Keep only this server's rows","in":"query","name":"server","schema":{"type":"string"}},{"description":"Keep only rows with this access reason (administrators only)","in":"query","name":"reason","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Effective tools"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"operation requires admin access"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"profile not found / client not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Effective tools of a profile","tags":["profiles"]}},"/api/v1/profiles/{name}/rename":{"post":{"description":"Renames a profile and moves every reference to it - token pins, client bindings, other profiles' switchable_to and the anonymous_profile - in one write (D19). Tokens move first, so a failure part-way never widens a scope. Guarded by FR-008a. Writes one ` + "`" + `rename` + "`" + ` record.","parameters":[{"description":"Profile name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.RenameProfileRequest"}}},"description":"new_name","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Renamed; moved lists the clients and tokens that followed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"profile not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ProfileConflictResponse"}}},"description":"profile_exists, or binding_bypassable_without_auth (BindingGuardResponse)"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Rename a profile","tags":["profiles"]}},"/api/v1/registries":{"get":{"description":"Retrieves list of all MCP server registries that can be browsed for discovering and installing new upstream servers. Includes registry metadata, server counts, and API endpoints.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetRegistriesResponse"}}},"description":"Registries retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to list registries"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List available MCP server registries","tags":["registries"]},"post":{"description":"Adds a generic modelcontextprotocol/registry v0.1 https endpoint as a custom registry (MCP-866). The source is always tagged custom/unverified, so every server discovered through it lands quarantined and can never skip quarantine.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddRegistrySourceRequest"}}},"description":"Registry source (https url + optional protocol/id/name)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source added"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/contracts.ErrorResponse"},{"$ref":"#/components/schemas/contracts.ErrorResponse"}]}}},"description":"Forbidden (agent tokens cannot mutate registries)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin | duplicate_registry"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a user-supplied registry source","tags":["registries"]}},"/api/v1/registries/{id}":{"delete":{"description":"Removes a custom/unverified registry previously added via add-source (MCP-1057). Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source removed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove a user-added custom registry source","tags":["registries"]},"put":{"description":"Updates a custom registry previously added via add-source (MCP-1072): name, url, servers-url. Empty fields are left unchanged. Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found; a non-https url yields invalid_registry_url. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.EditRegistrySourceRequest"}}},"description":"Fields to update (name/url/servers_url; empty = unchanged)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required | invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Edit a user-added custom registry source","tags":["registries"]}},"/api/v1/registries/{id}/refresh":{"post":{"description":"Invalidates the cached server lists for a registry so the next search re-fetches fresh data from the source (spec 070 FR-007). Returns how many cache entries were dropped.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.RefreshRegistryResponse"}}},"description":"Registry cache refreshed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh registry cache"}},"summary":"Refresh a registry's cached server list","tags":["registries"]}},"/api/v1/registries/{id}/servers":{"get":{"description":"Searches for MCP servers within a specific registry by keyword or tag. Returns server metadata including installation commands, source code URLs, and npm package information for easy discovery and installation.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Search query keyword","in":"query","name":"q","schema":{"type":"string"}},{"description":"Filter by tag","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Maximum number of results (default 10)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchRegistryServersResponse"}}},"description":"Servers retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to search servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search MCP servers in a registry","tags":["registries"]}},"/api/v1/registries/{id}/servers/{serverId}/add":{"post":{"description":"Resolves a registry server reference server-side, re-derives a validated config, and persists it quarantined (spec 070 keystone). The client never sends a config blob — command/args/url and the quarantine flag are derived from the registry entry, not the request.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Server ID within the registry","in":"path","name":"serverId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddFromRegistryRequest"}}},"description":"Optional overrides (name, env, enabled)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server added (quarantined)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"no_install_info | missing_required_input | duplicate_name"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot add servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found | server_not_found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add an upstream server from a registry reference","tags":["registries"]}},"/api/v1/review":{"get":{"description":"Returns one row per quarantined server or trusted server with pending or changed tools. Scoped callers see only servers they may enumerate.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Review queue"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to load review queue"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the server review queue","tags":["review"]}},"/api/v1/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints.\nrouting_mode is what /mcp is actually serving; pending_routing_mode carries a\nrestart-pending value persisted on disk (empty when there is none).\ntool_response_mode and direct_tool_response_mode report the two serialization axes, resolved.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/api/v1/secrets":{"post":{"description":"Stores a secret value in the operating system's secure keyring. The secret can then be referenced in configuration using ${keyring:secret-name} syntax. Automatically notifies runtime to restart affected servers.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored successfully with reference syntax"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload, missing name/value, or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to store secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Store a secret in OS keyring","tags":["secrets"]}},"/api/v1/secrets/{name}":{"delete":{"description":"Deletes a secret from the operating system's secure keyring. Automatically notifies runtime to restart affected servers. Only keyring type is supported for security.","parameters":[{"description":"Name of the secret to delete","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Secret type (only 'keyring' supported, defaults to 'keyring')","in":"query","name":"type","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret deleted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Missing secret name or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to delete secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a secret from OS keyring","tags":["secrets"]}},"/api/v1/servers":{"get":{"description":"Get a list of all configured upstream MCP servers with their connection status and statistics","parameters":[{"description":"Restrict to the profile's effective servers; tool_count becomes the number of that server's tools visible under the profile (Spec 108 FR-032)","in":"query","name":"profile","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unsupported scope filter (client/token), or '-' on a server filter"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown or unreachable profile"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List all upstream MCP servers","tags":["servers"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security. Isolation: ` + "`" + `isolation.enabled` + "`" + ` is READ-ONLY (it reports the effective state on reads) and is rejected with 400; set the per-server override via ` + "`" + `isolation.enabled_override` + "`" + ` (true | false | null to clear, omit to leave unchanged). An unrecognized ` + "`" + `isolation.mode_override` + "`" + ` is rejected with 400.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Conflict - server with this name already exists"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a new upstream server","tags":["servers"]}},"/api/v1/servers/disable_all":{"post":{"description":"Disable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk disable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable all servers","tags":["servers"]}},"/api/v1/servers/enable_all":{"post":{"description":"Enable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk enable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable all servers","tags":["servers"]}},"/api/v1/servers/import":{"post":{"description":"Import MCP server configurations from a Claude Desktop, Claude Code, Cursor IDE, Codex CLI, or Gemini CLI configuration file","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}},{"description":"Force format (claude-desktop, claude-code, cursor, codex, gemini)","in":"query","name":"format","schema":{"type":"string"}},{"description":"Comma-separated list of server names to import","in":"query","name":"server_names","schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"file"}}},"description":"Configuration file to import","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid file or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from uploaded configuration file","tags":["servers"]}},"/api/v1/servers/import/json":{"post":{"description":"Import MCP server configurations from raw JSON or TOML content (useful for pasting configurations)","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportRequest"}}},"description":"Import request with content","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid content or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from JSON/TOML content","tags":["servers"]}},"/api/v1/servers/import/path":{"post":{"description":"Import MCP server configurations by reading a file from the server's filesystem. A preview (preview=true) of a file with no MCP servers (empty, {}, or no/empty server map) answers 200 with an empty imported list; an apply of such a file and a malformed file still answer 400.","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportFromPathRequest"}}},"description":"Import request with file path","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid path or format"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"File not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from a file path","tags":["servers"]}},"/api/v1/servers/import/paths":{"get":{"description":"Returns well-known configuration file paths for supported formats with existence check","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPathsResponse"}}},"description":"Canonical config paths"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get canonical config file paths","tags":["servers"]}},"/api/v1/servers/reconnect":{"post":{"description":"Force reconnection to all upstream MCP servers","parameters":[{"description":"Reason for reconnection","in":"query","name":"reason","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"All servers reconnected successfully"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reconnect all servers","tags":["servers"]}},"/api/v1/servers/restart_all":{"post":{"description":"Restart all configured upstream MCP servers sequentially with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk restart results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart all servers","tags":["servers"]}},"/api/v1/servers/{id}":{"delete":{"description":"Remove an MCP upstream server from the configuration. This stops the server if running and removes it from config.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server removed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove an upstream server","tags":["servers"]},"patch":{"description":"Update specific fields of an existing upstream MCP server configuration. Isolation: ` + "`" + `isolation.enabled` + "`" + ` is READ-ONLY (it reports the effective state on reads) and is rejected with 400; set the per-server override via ` + "`" + `isolation.enabled_override` + "`" + ` (true | false | null to clear, omit to leave unchanged). An unrecognized ` + "`" + `isolation.mode_override` + "`" + ` is rejected with 400.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Fields to update (all optional)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server updated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - no fields or invalid body"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/config-to-secret":{"post":{"description":"Atomically reads the real value from the server config, stores it in the OS keyring, and rewrites the config field to ` + "`" + `${keyring:\u003cname\u003e}` + "`" + `. Unblocks the UI's Convert-to-secret affordance for values the API redacts on the read path.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored, config updated with reference"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad scope/key/secret_name, or value is already a reference / empty"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server or key not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver or config update failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Convert a header / env value to a keyring secret","tags":["servers"]}},"/api/v1/servers/{id}/disable":{"post":{"description":"Disable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server disabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/discover-tools":{"post":{"description":"Manually trigger tool discovery and indexing for a specific upstream MCP server. This forces an immediate refresh of the server's tool cache.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool discovery triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot discover tools)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to discover tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Discover tools for a specific server","tags":["servers"]}},"/api/v1/servers/{id}/enable":{"post":{"description":"Enable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server enabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/login":{"post":{"description":"Initiate OAuth authentication flow for a specific upstream MCP server. Returns structured OAuth start response with correlation ID for tracking.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthStartResponse"}}},"description":"OAuth login initiated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthFlowError"}}},"description":"OAuth error (client_id required, DCR failed, etc.)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Trigger OAuth login for server","tags":["servers"]}},"/api/v1/servers/{id}/logout":{"post":{"description":"Clear OAuth authentication token and disconnect a specific upstream MCP server. The server will need to re-authenticate before tools can be used again.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"OAuth logout completed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled or read-only mode)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Clear OAuth token and disconnect server","tags":["servers"]}},"/api/v1/servers/{id}/logs":{"get":{"description":"Retrieve log entries for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Number of log lines to retrieve","in":"query","name":"tail","schema":{"default":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerLogsResponse"}}},"description":"Server logs retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server logs","tags":["servers"]}},"/api/v1/servers/{id}/quarantine":{"post":{"description":"Place a specific upstream MCP server in quarantine to prevent tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server quarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Quarantine a server","tags":["servers"]}},"/api/v1/servers/{id}/refresh":{"post":{"description":"Re-discover and re-index a specific upstream MCP server's tools without changing any security state. Alias of discover-tools, named for the upstream_servers 'refresh' operation; use it to make just-approved tools searchable immediately.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool refresh triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot refresh)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Refresh a server's tools","tags":["servers"]}},"/api/v1/servers/{id}/restart":{"post":{"description":"Restart the connection to a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server restarted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/review":{"get":{"description":"Returns redacted server configuration, captured tool definitions, annotations, scan verdicts, and diffs. Scoped callers receive the same 404 for invisible and missing servers.","parameters":[{"description":"Server name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server review"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to load server review"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get review details for a server","tags":["review"]}},"/api/v1/servers/{id}/security/approve":{"post":{"description":"Saves the integrity baseline and optionally blocks selected tools atomically before unquarantining the server. With expected_hashes (tool name -\u003e the review's current_hash; {} for an empty review) the approval is bound to the reviewed definitions: when a tool the approval would make reachable (pending, or approved and enabled, minus block) changed or appeared since the review, or a block target no longer exists, the response is 409 review_out_of_date, nothing is written and the server stays quarantined. Tools listed after the approval wait for review.","parameters":[{"description":"Server name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.securityApproveRequest"}}},"description":"Force approval and tool names to keep disabled"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server approved"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid request or unknown tool block target"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not configured"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.StaleReviewErrorResponse"}}},"description":"Dangerous scan verdict requires force, approval failed, the review is out of date (error_code review_out_of_date), or server_replaced (the server was removed or re-added since expected_incarnation)"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Approve a server after security review","tags":["security"]}},"/api/v1/servers/{id}/tool-calls":{"get":{"description":"Retrieves tool call history filtered by upstream server ID. Returns recent tool executions for the specified server including timestamps, arguments, results, and errors. Useful for server-specific debugging and monitoring.","parameters":[{"description":"Upstream server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolCallsResponse"}}},"description":"Server tool calls retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get server tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history for specific server","tags":["tool-calls"]}},"/api/v1/servers/{id}/tools":{"get":{"description":"Retrieve all available tools for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolsResponse"}}},"description":"Server tools retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"server_replaced: the server kept being removed or replaced while its tools were read"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/approve":{"post":{"description":"Approves the given pending/changed tools (or all of them with approve_all). With expected_hashes (tool name -\u003e the review's current_hash) the approval is bound to the reviewed definitions: when a requested tool (approve_all: any pending or changed tool) no longer matches, the response is 409 review_out_of_date and nothing is approved.","parameters":[{"description":"Server name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.approveToolsRequest"}}},"description":"Tools to approve, or approve_all, with an optional review binding","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Approval outcome"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid request"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.StaleReviewErrorResponse"}}},"description":"The review is out of date; nothing was approved"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Approve tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/block":{"post":{"description":"Atomically approves AND disables the given tools (or all pending/changed tools when block_all=true) for a server. The approve and disable land in a single write per tool, so a tool is never left in the approved+enabled state. The \"blocked\" field counts tools actually blocked. An optional expected_incarnation binds the write to the reviewed server (Spec 116): a remove and re-add since then answers 409 server_replaced and writes nothing.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Block result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"server_replaced: the server was removed or re-added since expected_incarnation"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Block (approve+disable) tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/disable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.expectedIncarnationBody"}}},"description":"Optional incarnation of the reviewed server (Spec 116)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"server_replaced: the server was removed or re-added since expected_incarnation"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/enable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.expectedIncarnationBody"}}},"description":"Optional incarnation of the reviewed server (Spec 116)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"server_replaced: the server was removed or re-added since expected_incarnation"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/unquarantine":{"post":{"description":"Remove a specific upstream MCP server from quarantine to allow tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.expectedIncarnationBody"}}},"description":"Optional incarnation of the reviewed server (Spec 116)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server unquarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"server_replaced: the server was removed or re-added since expected_incarnation"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Unquarantine a server","tags":["servers"]}},"/api/v1/sessions":{"get":{"description":"Retrieves paginated list of active and recent MCP client sessions. Each session represents a connection from an MCP client to MCPProxy, tracking initialization time, tool calls, and connection status.","parameters":[{"description":"Maximum number of sessions to return (1-100, default 10)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of sessions to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter by session status","in":"query","name":"status","schema":{"enum":["active","closed"],"type":"string"}},{"description":"Filter by the session's latest effective profile; - selects sessions with none (Spec 108)","in":"query","name":"profile","schema":{"type":"string"}},{"description":"Filter by the client id the session's credential is bound to; - selects sessions with none (Spec 108)","in":"query","name":"client","schema":{"type":"string"}},{"description":"Filter by the token name the session initialized with; - selects sessions with none (Spec 108)","in":"query","name":"token","schema":{"type":"string"}},{"description":"Alias of token","in":"query","name":"agent","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionsResponse"}}},"description":"Sessions retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid status filter, token and agent naming different tokens, or client_name (not supported here; filter by client)"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read MCP session history"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get sessions"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get active MCP sessions","tags":["sessions"]}},"/api/v1/sessions/{id}":{"get":{"description":"Retrieves detailed information about a specific MCP client session including initialization parameters, connection status, tool call count, and activity timestamps.","parameters":[{"description":"Session ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionDetailResponse"}}},"description":"Session details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read MCP session history"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get MCP session details by ID","tags":["sessions"]}},"/api/v1/stats/tokens":{"get":{"description":"Retrieve token savings statistics across all servers and sessions","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Token statistics"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read deployment-wide token statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get token savings statistics","tags":["stats"]}},"/api/v1/status":{"get":{"description":"Get comprehensive server status including running state, listen address, upstream statistics, and timestamp\ntelemetry (admin only): effective telemetry state {enabled, source: env|config|default, disabled_by}","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server status","tags":["status"]}},"/api/v1/telemetry/payload":{"get":{"description":"Render the exact JSON heartbeat payload that mcpproxy would next send to the telemetry endpoint, without making a network call. Counters in the payload reflect the current in-memory state. Spec 042.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Telemetry heartbeat payload"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read the deployment telemetry payload"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Telemetry service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview next telemetry heartbeat payload","tags":["telemetry"]}},"/api/v1/telemetry/update-failure":{"post":{"description":"Records one terminal update-session failure, identified only by its\nstage (appcast, download, install, other). The body carries no error\ntext, URL, or version — the stage is the only value transmitted.\nReturns 204 both when the occurrence was durably persisted and when\ntelemetry is inactive at event time (config opt-out, environment\nopt-out, CI, or dev build), in which case nothing is recorded.\nCallers cannot and need not distinguish the two.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UpdateFailureRequest"}}},"description":"Update failure stage","required":true},"responses":{"204":{"description":"Accepted (recorded, or a deliberate no-op while telemetry is inactive)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Malformed body, unknown field, trailing value, or stage outside the closed set"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Persistence failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Record a desktop auto-update failure occurrence (Spec 095)","tags":["telemetry"]}},"/api/v1/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools":{"get":{"description":"Consolidated, read-only listing of all tools from every configured server (including disabled servers and disabled/config-denied tools), enriched with approval state and 30-day usage. Backs the global Tools page and the CLI global ` + "`" + `tools list` + "`" + ` (spec 050, issue #437).","parameters":[{"description":"View-as (administrator only): the tools this client would see, each with its access verdict {visible, callable, reason} and profile_tier (Spec 108 FR-032)","in":"query","name":"client","schema":{"type":"string"}},{"description":"View-as: the tools this profile would expose. An administrator gets every row with a verdict; any other caller gets only the visible rows plus counts {visible, hidden} (Spec 108 FR-032)","in":"query","name":"profile","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GlobalToolsResponse"}}},"description":"All tools across all servers"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unsupported scope filter (token), both client and profile, or '-' on a tools filter"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"client= requires administrator credentials"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown or unreachable client / profile"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Could not enumerate servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List every tool across all servers","tags":["tools"]}},"/api/v1/tools/call":{"post":{"description":"Execute a tool on an upstream MCP server (wrapper around MCP tool calls)","requestBody":{"content":{"application/json":{"schema":{"properties":{"arguments":{"type":"object"},"tool_name":{"type":"string"}},"type":"object"}}},"description":"Tool call request with tool name and arguments","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Tool call result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (invalid payload or missing tool name)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}}, + "paths": {"/api/v1/access/explain":{"get":{"description":"For one subject (exactly one of client=\u003cid\u003e, token=\u003cname\u003e, profile=\u003cname\u003e or anonymous=true) and one upstream tool (server:tool), returns the ordered chain of gates a real call would meet - credential, profile, server_in_scope, tool_rule, tier_cap, token_permission, global_gate, server_state, tool_approval - the overall verdict (allowed, blocked, hidden), the first failing step and the fixes for it in preference order. The chain is the one every discovery and dispatch path walks, so ` + "`" + `allowed` + "`" + ` equals what a real call does. Built-in management tools are not explained (400). A client credential is explained with client=, never token=. Administrators only.","parameters":[{"description":"Upstream tool as server:tool","in":"query","name":"tool","required":true,"schema":{"type":"string"}},{"description":"Client id","in":"query","name":"client","schema":{"type":"string"}},{"description":"Regular agent token name","in":"query","name":"token","schema":{"type":"string"}},{"description":"Profile name","in":"query","name":"profile","schema":{"type":"string"}},{"description":"Explain an anonymous (credential-less) caller","in":"query","name":"anonymous","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"The explanation"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Not exactly one subject, a built-in tool, or token=client-\u003cid\u003e"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"client / token / profile not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Explain why a tool is or is not reachable","tags":["access"]}},"/api/v1/activity":{"get":{"description":"Returns paginated list of activity records with optional filtering","parameters":[{"description":"Filter by activity type(s), comma-separated for multiple (Spec 024)","in":"query","name":"type","schema":{"enum":["tool_call","policy_decision","quarantine_change","server_change","system_start","system_stop","internal_tool_call","config_change","preflight","prompt_get","profile_change"],"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Filter by parent call id — returns the sub-calls one code_execution issued","in":"query","name":"parent_id","schema":{"type":"string"}},{"description":"Filter by call-error class (Spec 113-c)","in":"query","name":"error_class","schema":{"enum":["network","timeout","http","jsonrpc","tool_error","session_terminated","auth","proxy_policy","proxy_internal","cancelled"],"type":"string"}},{"description":"Filter by fault domain (Spec 113-c)","in":"query","name":"fault_domain","schema":{"enum":["upstream","proxy","client"],"type":"string"}},{"description":"Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)","in":"query","name":"include_call_tool","schema":{"type":"boolean"}},{"description":"Filter by sensitive data detection (true=has detections, false=no detections)","in":"query","name":"sensitive_data","schema":{"type":"boolean"}},{"description":"Filter by specific detection type (e.g., 'aws_access_key', 'credit_card')","in":"query","name":"detection_type","schema":{"type":"string"}},{"description":"Filter by severity level","in":"query","name":"severity","schema":{"enum":["critical","high","medium","low"],"type":"string"}},{"description":"Alias of token (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by the token in effect for the call; - selects records with no token (Spec 108)","in":"query","name":"token","schema":{"type":"string"}},{"description":"Filter by the profile in effect for the call; - selects records with no profile (Spec 108)","in":"query","name":"profile","schema":{"type":"string"}},{"description":"Filter by client id; - selects records with no client (Spec 108)","in":"query","name":"client","schema":{"type":"string"}},{"description":"Filter by the client's self-reported name (advisory; Spec 108)","in":"query","name":"client_name","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Omit arguments, response and metadata except a contextual whitelist (intent.reason, intent.operation_type, decision, reason, client_name, client_version) (default: false). For clients that render summary fields only; has_sensitive_data is still derived before metadata is dropped.","in":"query","name":"exclude_payloads","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"List activity records","tags":["Activity"]}},"/api/v1/activity/export":{"get":{"description":"Exports activity records in JSON Lines or CSV format for compliance","parameters":[{"description":"Export format: json (default) or csv","in":"query","name":"format","schema":{"type":"string"}},{"description":"Filter by activity type","in":"query","name":"type","schema":{"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Filter by parent call id — exports the sub-calls one code_execution issued","in":"query","name":"parent_id","schema":{"type":"string"}},{"description":"Filter by call-error class (Spec 113-c)","in":"query","name":"error_class","schema":{"enum":["network","timeout","http","jsonrpc","tool_error","session_terminated","auth","proxy_policy","proxy_internal","cancelled"],"type":"string"}},{"description":"Filter by fault domain (Spec 113-c)","in":"query","name":"fault_domain","schema":{"enum":["upstream","proxy","client"],"type":"string"}},{"description":"Alias of token (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by the token in effect for the call; - selects records with no token (Spec 108)","in":"query","name":"token","schema":{"type":"string"}},{"description":"Filter by the profile in effect for the call; - selects records with no profile (Spec 108)","in":"query","name":"profile","schema":{"type":"string"}},{"description":"Filter by client id; - selects records with no client (Spec 108)","in":"query","name":"client","schema":{"type":"string"}},{"description":"Filter by the client's self-reported name (advisory; Spec 108)","in":"query","name":"client_name","schema":{"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to export (1-50000, default 10000)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"application/x-ndjson":{"schema":{"type":"string"}},"text/csv":{"schema":{"type":"string"}}},"description":"Streamed activity records"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Export activity records","tags":["Activity"]}},"/api/v1/activity/summary":{"get":{"description":"Returns aggregated activity statistics for a time period","parameters":[{"description":"Time period: 1h, 24h (default), 7d, 30d","in":"query","name":"period","schema":{"type":"string"}},{"description":"Group by: server, tool (optional)","in":"query","name":"group_by","schema":{"type":"string"}},{"description":"Alias of token (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Count only records made under this token; - selects records with no token (Spec 108)","in":"query","name":"token","schema":{"type":"string"}},{"description":"Count only records made under this profile; - selects records with no profile (Spec 108)","in":"query","name":"profile","schema":{"type":"string"}},{"description":"Count only records made by this client id; - selects records with no client (Spec 108)","in":"query","name":"client","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity summary statistics","tags":["Activity"]}},"/api/v1/activity/usage":{"get":{"description":"Returns the actor-owned usage aggregate (per-tool rollup + timeline + tokens-saved headline) for the Web UI usage graphs (Spec 069). Served from an in-memory snapshot — never a per-request full-log scan. Per-tool metrics are lifetime-cumulative; ` + "`" + `window` + "`" + ` scopes the timeline and filters the tool list to tools active within the span.","parameters":[{"description":"Time window for timeline + tool-list membership","in":"query","name":"window","schema":{"enum":["24h","7d","all"],"type":"string"}},{"description":"Filter to one server","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter to one tool","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter to tools with activity of this status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Top-N tools by sort key; remainder folded into 'other' (default 20)","in":"query","name":"top","schema":{"type":"integer"}},{"description":"Ranking key for the per-tool list","in":"query","name":"sort","schema":{"enum":["calls","resp_bytes","error_rate","p95"],"type":"string"}},{"description":"Alias of token (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Restrict to calls made under this token; - selects records with no token. Computed from a window-bounded scan (Spec 108)","in":"query","name":"token","schema":{"type":"string"}},{"description":"Restrict to calls made under this profile; - selects records with no profile. Computed from a window-bounded scan (Spec 108)","in":"query","name":"profile","schema":{"type":"string"}},{"description":"Restrict to calls made by this client id; - selects records with no client. Computed from a window-bounded scan (Spec 108)","in":"query","name":"client","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get usage statistics aggregate","tags":["Activity"]}},"/api/v1/activity/{id}":{"get":{"description":"Returns full details for a single activity record","parameters":[{"description":"Activity record ID (ULID)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity record details","tags":["Activity"]}},"/api/v1/annotations/coverage":{"get":{"description":"Reports how many upstream tools have MCP annotations vs don't, broken down by server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Annotation coverage report"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get annotation coverage report","tags":["annotations"]}},"/api/v1/attention":{"get":{"description":"One list, one count, every surface (Web UI, macOS tray/Home, CLI) reads from it. Filtered per caller: an administrator sees every item; a scoped caller (agent token, or a non-admin server-edition OAuth user session) sees only server items whose server it may enumerate; every other subject type (client, setting) is administrator-only.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.attentionResponse"}}},"description":"The needs-attention list"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the needs-attention list","tags":["attention"]}},"/api/v1/catalog/search":{"get":{"description":"Fans out to every enabled catalog source (registry) in parallel, merges, de-duplicates and ranks the results (FR-060/061). An empty q returns official + popular sections instead of a flat list. Open to any authenticated caller — added is the only field filtered per caller scope (FR-007).","parameters":[{"description":"Free-text search","in":"query","name":"q","schema":{"type":"string"}},{"description":"Narrow to one catalog source id","in":"query","name":"source","schema":{"type":"string"}},{"description":"Not supported: catalog entries carry no tags; a non-empty value returns 400","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Max results (default 20, max 50)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"tag filtering is not supported"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"configured servers could not be listed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search the server catalog across every enabled source","tags":["catalog"]}},"/api/v1/clients":{"get":{"description":"Lists every client row (supported, other and custom) with its presence, its credential and its profile binding, plus response-level warnings. The list is content-read-free (Spec 075): credential_state comes from the token store, else the last on-demand observation (credential_checked_at), else unknown. It runs only the time-based half of the rotation reconciler. The profile and client filters match the CURRENT binding and are applied AFTER warnings are computed over the full set.","parameters":[{"description":"Rows whose active credential is bound to this profile; - = bound to All servers","in":"query","name":"profile","schema":{"type":"string"}},{"description":"Exact client id","in":"query","name":"client","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Client rows, routing and warnings"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List clients","tags":["clients"]},"post":{"description":"Creates a per-client credential for a client that is NOT in the connect registry (a script, a CI job, an editor without a connect adapter). The secret (mcp_cli_...) is returned ONCE, with a paste-ready snippet that carries it in the X-API-Key header. The id follows the client-id rule (lower-case letters, digits, '-' or '_', at most 56 characters) and must not be a supported client's id. A named profile binds the credential locked unless mode says otherwise. Refused with 409 binding_bypassable_without_auth when the binding would be bypassable while require_mcp_auth is off (FR-008a), and 409 conflicting_token when a regular token holds client-\u003cid\u003e. Every value (id, display_name, profile, mode, expires_in, purpose) passes the shared secret-shaped input screen first: a credential, the API key or a detector-flagged secret answers 400 secret_in_argument naming the field, and nothing is minted. A malformed body answers a sanitized 400 that never echoes a caller key or value. Writes one profile_change record with change issue (Spec 115) and publishes credentials.changed. Personal edition only.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CreateClientRequest"}}},"description":"id (required), optional display_name, profile, mode, expires_in and purpose","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"The new client row, its credential (once) and a snippet"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.BindingGuardResponse"}}},"description":"binding_bypassable_without_auth, or conflicting_token (ClientCredentialConflictResponse)"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a custom client","tags":["clients"]}},"/api/v1/clients/bulk-assign":{"post":{"description":"Moves every client credential bound to from_profile (\"\" = All servers) onto to_profile (\"\" = All servers). The guard and the credential precondition apply PER CLIENT: a refused client is reported in skipped[] with the code the single operation returns, the others move and each writes its own assign record and emits client.binding_changed. Personal edition only.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.BulkAssignRequest"}}},"description":"from_profile and to_profile (both required; empty = All servers) and optional mode","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Moved client ids and the clients that were skipped"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Move every client of one profile to another","tags":["clients"]}},"/api/v1/clients/upgrade-admin-key-holders":{"post":{"description":"Without apply: classifies every supported client whose config still holds the instance admin API key (an on-demand read per client) and returns preview[] (display path, what would change, the masked credential mcp_cli_••••, the binding and a precondition_token per client), a combined precondition_token, and guard when the whole request would be refused by FR-008a; next_step is rotate_admin_api_key when nothing holds the key. Nothing is written or minted. With apply=true: recomputes the preview, refuses with 409 precondition_failed when a sent precondition_token no longer matches, runs the FR-008a guard over the WHOLE request (409 binding_bypassable_without_auth, nothing minted, no file written), then connects each client with force bound to its own token; one client's failure does not stop the others. Each mint writes its own assign record. The guard only applies when a named profile is given. Personal edition only.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UpgradeAdminKeyHoldersRequest"}}},"description":"Optional profile and mode for every upgraded client; apply and precondition_token"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"UpgradePreview (no apply) or UpgradeResult (apply)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.BindingGuardResponse"}}},"description":"precondition_failed, or binding_bypassable_without_auth"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Upgrade every client that holds the admin API key","tags":["clients"]}},"/api/v1/clients/{client}":{"delete":{"description":"Revokes the client's credential; it stops authenticating at once. With disconnect=true a SUPPORTED client's mcpproxy entry is removed from its config FIRST, but the credential is revoked either way: revocation never waits for a config write, so a failed write (for example a macOS App-Data denial) leaves the client cut off and is reported as disconnected:false with disconnect_error. A client with no credential record is 409 no_client_credential. disconnect=true on a custom client is ignored. Writes one forget record naming the revoked token. The FR-008a guard is not involved: revoking only removes a binding.","parameters":[{"description":"Client id","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Also remove the entry from the client's config (supported clients)","in":"query","name":"disconnect","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Revoked token name and what happened to the config entry"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"no_client_credential"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Forget a client (revoke its credential)","tags":["clients"]},"get":{"description":"Returns one client row with its sessions (newest 20, each with its latest effective profile). This is the only read that may open the client's config file: it runs the full rotation reconciler first, classifies the credential the config holds and records the observation. No scope filter is honoured on this route.","parameters":[{"description":"Client id","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Client row"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"client not found"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get one client","tags":["clients"]}},"/api/v1/clients/{client}/binding":{"put":{"description":"Changes the profile binding (and optionally the locked/switchable mode) of a\nclient that holds an active client credential. One service operation shared by\nevery surface: it updates the credential in the token store, clears the stored\nset_profile selection of every live session of that credential, sends\nnotifications/tools/list_changed to each, emits client.binding_changed and writes\none profile_change activity record. It never touches the client's config file.\nA client without an active client credential (none, admin key, revoked or expired)\nis refused with 409 no_client_credential and nothing is minted or written. A\nreassignment that would leave the binding bypassable while require_mcp_auth is off\nis refused with 409 binding_bypassable_without_auth (FR-008a). Personal edition only.","parameters":[{"description":"Client id (lower-case letters, digits, '-' or '_', at most 56 characters)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.PutClientBindingRequest"}}},"description":"profile (required; empty = All servers) and optional mode (locked|switchable)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"The client row after the change, with its warnings"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.BindingGuardResponse"}}},"description":"no_client_credential, connect_in_progress (a connect of this client is mid-write), or binding_bypassable_without_auth with bindings and fixes"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reassign a client's profile and/or mode","tags":["clients"]}},"/api/v1/clients/{client}/rotate":{"post":{"description":"Replaces a client's secret without ever invalidating the old one mid-flight (FR-021a). A SUPPORTED client is rewritten through connect: staged rotation, binding kept, finalized when the config write succeeds, rolled back (the old secret keeps working) when it fails; a preview's precondition_token binds the call, a mismatch is the connect 409 precondition_failed. A CUSTOM client gets a new secret returned ONCE with a snippet; both secrets authenticate until POST /clients/{client}/rotate/finalize or 24 hours. A client with no active credential is 409 no_client_credential. The FR-008a guard is not involved: a rotation never changes a binding's reach. Writes one rotate record when it finalizes.","parameters":[{"description":"Client id","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.RotateClientRequest"}}},"description":"Optional precondition_token of a connect preview"},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"The client row and the rotation state"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required (or macOS App-Data block)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectConflictResponse"}}},"description":"no_client_credential, precondition_failed, connect_in_progress (another connect of this client is mid-write), or credential_superseded (the written credential was replaced or revoked before it could be finalized; reconnect)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Rotate a client's credential","tags":["clients"]}},"/api/v1/clients/{client}/rotate/finalize":{"post":{"description":"Promotes a client's pending secret: the old secret stops authenticating. Idempotent: with no rotation in progress it is a no-op success. A client with no client credential is 409 no_client_credential. Writes one rotate record when it promotes.","parameters":[{"description":"Client id","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"The client row; rotation.state is finalized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"no_client_credential, or connect_in_progress (a connect of this client is mid-write)"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Finalize a staged rotation","tags":["clients"]}},"/api/v1/code/scripts":{"get":{"description":"List the stored scripts available to the code_execution tool. Scripts are ` + "`" + `\u003cname\u003e.js` + "`" + ` / ` + "`" + `\u003cname\u003e.ts` + "`" + ` files in the ` + "`" + `scripts/` + "`" + ` directory next to the active configuration file. Entries are advisory: ` + "`" + `ok` + "`" + ` scripts are invocable, ` + "`" + `ambiguous` + "`" + ` names have both extensions, and ` + "`" + `invalid` + "`" + ` ones report why (empty, oversized, unreadable, non-regular). Read-only — there is no write surface for stored scripts. Administrator-only (Spec 105 FR-012): an agent token, whatever its server scope, is refused with 403 — the listing is the enumeration the missing-script error withholds from a scoped caller.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Stored scripts and the directory they were read from"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot list stored scripts"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List stored code-execution scripts","tags":["code"]}},"/api/v1/config":{"get":{"description":"Retrieves the current MCPProxy configuration including all server definitions, global settings, and runtime parameters","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetConfigResponse"}}},"description":"Configuration retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read the configuration document"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get current configuration","tags":["config"]},"patch":{"description":"Deep-merges only the fields present in the request body onto the live in-memory configuration and routes the result through the existing apply pipeline (validation, change detection, disk persistence, hot-reload). Fields the client omits — including masked secrets such as ` + "`" + `api_key` + "`" + ` and secret request headers — are preserved verbatim. Nested objects are merged recursively; arrays and scalars replace wholesale.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Partial configuration with only the fields to change","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration patch applied (inspect validation_errors for rejected values)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload or empty patch"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.BindingGuardResponse"}}},"description":"binding_bypassable_without_auth: the patch would let a client bound to a named profile escape it while require_mcp_auth is off (FR-008a); nothing was written"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"telemetry.enabled is locked while an environment variable (DO_NOT_TRACK, CI, MCPPROXY_TELEMETRY=false) disables telemetry; nothing was written (FR-044a)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to read or apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update configuration","tags":["config"]}},"/api/v1/config/apply":{"post":{"description":"Applies a new MCPProxy configuration. Validates and persists the configuration to disk. Some changes apply immediately, while others may require a restart. Returns detailed information about applied changes and restart requirements.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to apply","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration applied successfully with change details"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.BindingGuardResponse"}}},"description":"binding_bypassable_without_auth: the write would let a client bound to a named profile escape it while require_mcp_auth is off (FR-008a); nothing was written"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"telemetry.enabled is locked while an environment variable (DO_NOT_TRACK, CI, MCPPROXY_TELEMETRY=false) disables telemetry; nothing was written (FR-044a)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Apply configuration","tags":["config"]}},"/api/v1/config/docker-isolation":{"patch":{"description":"Convenience endpoint to flip ` + "`" + `docker_isolation.enabled` + "`" + ` without resending the full config. Persists to disk via the existing config writer — the file watcher then hot-reloads the change. Returns the new state and whether a restart is required for existing connections to pick it up.","requestBody":{"content":{"application/json":{"schema":{"properties":{"enabled":{"type":"boolean"}},"type":"object"}}},"description":"New isolation state","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Isolation toggle applied"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.BindingGuardResponse"}}},"description":"binding_bypassable_without_auth (FR-008a); nothing was written"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Toggle global Docker isolation","tags":["config"]}},"/api/v1/config/validate":{"post":{"description":"Validates a provided MCPProxy configuration without applying it. Checks for syntax errors, invalid server definitions, conflicting settings, and other configuration issues.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to validate","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ValidateConfigResponse"}}},"description":"Configuration validation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Validation failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Validate configuration","tags":["config"]}},"/api/v1/connect":{"get":{"description":"Returns the connection status for all known MCP client applications.\nEach entry indicates whether the client config file exists and whether\nMCPProxy is currently registered in it. This stat-only listing never reads\na client config (Spec 075), so a client whose config exists reports\ncredential_state \"unknown\"; GET /connect/{client} resolves it.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"List of ClientStatus objects"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List client connection status","tags":["connect"]}},"/api/v1/connect/{client}":{"delete":{"description":"Remove the MCPProxy entry from the specified client's configuration file.\nCreates a backup of the existing config before modifying.\nAlso revokes the client's credential (it stops authenticating at once); the result names it in credential_revoked, or reports credential_revoke_error when the revoke failed after the entry was removed. Undoing a disconnect does not restore the credential: a later connect mints a new one.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode, zcode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional parameters (server_name)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or entry not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disconnect MCPProxy from a client","tags":["connect"]},"get":{"description":"Resolves one client's status by reading its config file on demand.\nThis is the only Connect endpoint that opens a client config file, so\non macOS it is the sole place an App-Data privacy prompt may legitimately\nappear (scoped to this user action). Resolves access_state to\naccessible|absent|denied|malformed and populates remediation when denied.\nFor a connected client it also resolves credential_state (Spec 108 FR-025):\nclient (an active per-client credential), admin_key (the instance admin API\nkey), none, revoked (a rotated-away or revoked credential) or expired. The\ncredential value is classified and never echoed.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode, zcode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ClientStatus"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get a single client's connection status (on-demand)","tags":["connect"]},"post":{"description":"Register MCPProxy as an MCP server in the specified client's configuration file.\nCreates a backup of the existing config before modifying.\nOptionally accepts precondition_token from a preview (Spec 091): when supplied,\nthe core rechecks the raw pre-write state and the entry it would write, and\nrefuses a drifted write with 409 before taking any backup. The 409 body's\naction discriminates the two conflict kinds: \"precondition_failed\" (stale\npreview — re-preview, do not retry) vs \"already_exists\" (entry present — pass\nforce=true). force=true never rescues a stale token.\n\nSpec 108 FR-024: the entry carries a per-client mcp_cli_ credential bound to the\nrequested profile (default: All servers, switchable; a reconnect keeps the client's\nexisting binding unless a profile is given), never the instance admin API key.\nReconnecting over an active credential is a staged rotation: the old secret keeps\nworking until the config write succeeds. keyless=true writes no credential and is\nonly possible while require_mcp_auth is off (400 otherwise, and with a profile).\nA write that would let the client escape its profile by omitting its credential\nwhile require_mcp_auth is off is refused with 409 binding_bypassable_without_auth\n(nothing minted or written); a token named client-\u003cid\u003e held by a regular agent\ntoken is refused with 409 conflicting_token.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode, zcode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional connection parameters (server_name, force, precondition_token, profile, mode, keyless)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult (credential is the masked mcp_cli_ value, with token_name, profile, mode, keyless and rotation)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Bad request; field names the offending input (profile, mode, keyless)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectConflictResponse"}}},"description":"Conflict: action=already_exists (use force=true) or action=precondition_failed (preview is stale; re-preview); or binding_bypassable_without_auth (BindingGuardResponse); or conflicting_token (ClientCredentialConflictResponse); or connect_in_progress (another connect of this client is mid-write) or credential_superseded (the written credential was replaced or revoked before it could be finalized; reconnect)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable (or no credential store wired while require_mcp_auth is on)"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Connect MCPProxy to a client","tags":["connect"]}},"/api/v1/connect/{client}/preview":{"get":{"description":"Returns the exact entry a subsequent connect would add to the client's\nconfig — target path, server key, entry name, and entry contents — WITHOUT\nmodifying the file or creating a backup (Spec 078 US1). The per-client credential\nis masked in the payload (credential, always starting mcp_cli_; contains_api_key is\nalways false since connect never writes the admin API key); profile, mode and\nkeyless echo the requested intent.\nentry_exists distinguishes a create from an overwrite of a same-named entry.\nReads the config on demand to classify create-vs-overwrite, so on macOS this\nmay raise an App-Data privacy prompt; a denial returns 403 + remediation.\nSpec 091 adds three fields: existing_entry_summary (present only when\nentry_exists — a sanitized, non-secret projection of the entry being replaced:\nits name, type, endpoint with query/userinfo stripped, command, and header and\nenv NAMES, never values); precondition_token (always present — an opaque keyed\ndigest of the raw pre-write state and the pending entry, echoed back on POST\nconnect to detect drift); and connect_refusal (present when the write would\nrefuse regardless of intent, e.g. a non-create-capable client with no config —\ntreat its presence as \"Connect unavailable\").","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode, zcode)","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Entry name to preview (defaults to mcpproxy); mirror the value passed to POST connect","in":"query","name":"server_name","schema":{"type":"string"}},{"description":"Profile the client credential would bind to (empty = All servers); mirror POST connect","in":"query","name":"profile","schema":{"type":"string"}},{"description":"locked|switchable; mirror POST connect","in":"query","name":"mode","schema":{"enum":["locked","switchable"],"type":"string"}},{"description":"Preview a credential-less entry (only while require_mcp_auth is off)","in":"query","name":"keyless","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectPreview"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid intent (field names the input; keyless with auth on or with a profile)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required or access denied by macOS App Data"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview the change a connect would make (no write)","tags":["connect"]}},"/api/v1/connect/{client}/undo":{"post":{"description":"Reverts the connect that produced the named backup (Spec 078 US3):\nrestores the client config byte-for-byte from that backup, or — when\nbackup_name is empty because the connect created the file — deletes the\ncreated file. backup_name is the bare filename of the backup the connect\nreturned (never a path); undo resolves the full path server-side inside\nthe client's own config directory, so a client value cannot escape it.\nRefuses with 409 when the config changed since the connect (undo never\nclobbers later edits; use DELETE /connect/{client} for a surgical entry\nremoval instead). Takes its own safety backup first; its path is returned\nas backup_path in the result. Undo is \"as if the connect never happened\": a\nclient credential the connect minted or rotated is revoked unless the restored\nconfig still holds it, and the result names it in credential_revoked.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode, zcode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UndoConnectRequest"}}},"description":"Undo parameters (server_name, backup_name = the bare filename of the backup the preceding connect returned)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult (action restored|deleted)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (e.g. backup_name is a path, or not a backup of this client's config)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or backup no longer exists"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Config changed since connect; undo refused"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Undo a connect, restoring the pre-connect config","tags":["connect"]}},"/api/v1/diagnostics":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/docker/status":{"get":{"description":"Retrieve current Docker availability and recovery status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Docker status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get Docker status","tags":["docker"]}},"/api/v1/doctor":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/feedback":{"post":{"description":"Submit a bug report, feature request, or general feedback","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackRequest"}}},"description":"Feedback request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Bad Request"},"429":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyAuth":[]}],"summary":"Submit feedback","tags":["feedback"]}},"/api/v1/index/search":{"get":{"description":"Search across all upstream MCP server tools using BM25 keyword search","parameters":[{"description":"Search query","in":"query","name":"q","required":true,"schema":{"type":"string"}},{"description":"Maximum number of results","in":"query","name":"limit","schema":{"default":10,"maximum":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchToolsResponse"}}},"description":"Search results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing query parameter)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search for tools","tags":["tools"]}},"/api/v1/info":{"get":{"description":"Get essential server metadata including version, web UI URL, endpoint addresses, and update availability\nweb_ui_url carries the ?apikey= credential ONLY for an authenticated admin; a scoped agent token receives the bare URL\nThis endpoint is designed for tray-core communication and version checking\nUse refresh=true query parameter to force an immediate update check against GitHub\nThe launched_by field reports durable launch provenance (\"tray\", \"installer\", or \"\" for user-launched/unknown)","parameters":[{"description":"Force immediate update check against GitHub","in":"query","name":"refresh","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Server information with optional update info"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server information","tags":["status"]}},"/api/v1/onboarding/mark":{"post":{"description":"Updates wizard engagement and per-step status. Once engaged is\ntrue, the wizard does not auto-show again, even if state regresses.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.OnboardingMarkRequest"}}},"description":"Mark request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Updated OnboardingStateResponse"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read onboarding state"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Mark onboarding wizard state (Spec 046)","tags":["onboarding"]}},"/api/v1/onboarding/state":{"get":{"description":"Returns the wizard engagement record alongside live predicates\n(whether any client is connected, whether any server is configured),\nplus a derived ShouldShowWizard flag the frontend can rely on.","responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OnboardingStateResponse"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read onboarding state"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get onboarding wizard state and predicates (Spec 046)","tags":["onboarding"]}},"/api/v1/preflight":{"post":{"description":"Deterministic, side-effect-free availability check for a caller-supplied list of tool IDs (Spec 098). Performs zero upstream calls and mutates no runtime state. HTTP status reports whether the CHECK executed: a fully blocked set is still 200, with the availability verdict in the body. Every executed preflight writes an activity record before the response is returned.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.PreflightRequest"}}},"description":"Tool IDs (1-100 before dedup), optional profile, annotation policy filters and wait budget","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Preflight verdict and per-tool results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Validation error (malformed, oversized, doubled or unknown-field body; empty or oversized tool list; conflicting duplicate pins; unknown profile; wait_ms out of range)"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Missing or invalid credentials"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Runtime unavailable, evaluator infrastructure read failure, or the activity record could not be persisted"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Preflight required tools","tags":["tools"]}},"/api/v1/profiles":{"get":{"description":"Lists every profile as a ProfileView: the config fields as stored, the derived effective values, visible tool counts by tier, 24 h calls and blocked calls, and (administrators only) used_by and the anonymous_profile. A caller that is not an administrator sees only the profiles its entitlement reaches, with servers, rules and switchable_to narrowed to what it may see; an unreachable profile is omitted, never shown empty.","responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Profile list"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Configuration unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List configured profiles","tags":["profiles"]},"post":{"description":"Adds a profile. The body is a ProfileConfig. A name that already exists is 409 profile_exists; ` + "`" + `active` + "`" + ` and ` + "`" + `try` + "`" + ` are reserved by the REST API; a fatal validation rule is 400 with the offending field and the unchanged FR-007 text. A write that would let a client bound to a named profile escape it while require_mcp_auth is off is refused 409 binding_bypassable_without_auth (FR-008a) and nothing is written. Writes one profile_change record.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.ProfileConfig"}}},"description":"The profile","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Created, with the validator's warnings"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ProfileConflictResponse"}}},"description":"profile_exists, or binding_bypassable_without_auth (BindingGuardResponse)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Create a profile","tags":["profiles"]}},"/api/v1/profiles/active":{"get":{"description":"Deprecated (Spec 108 FR-039; sends a Deprecation header, successor GET /api/v1/profiles). Get the server-level default active profile used by UI surfaces (Web UI / tray). Empty string means \"all servers\". Note: within a live MCP session, the set_profile tool selection takes precedence over this default.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the default active profile","tags":["profiles"]},"put":{"description":"Deprecated (Spec 108 FR-039; sends a Deprecation header, successor GET /api/v1/profiles). Set the server-level default active profile for UI surfaces. The slug must match a configured profile; pass an empty string to clear. This does not affect live MCP sessions, which use the set_profile tool.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.SetActiveProfileRequest"}}},"description":"Profile slug to activate (empty clears)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid request body"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot change the active profile)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Set the default active profile","tags":["profiles"]}},"/api/v1/profiles/try":{"post":{"description":"Evaluates a DRAFT profile against a query exactly as retrieve_tools would (policy is applied before the limit) and returns the hits plus what the draft hides (hidden_by_profile and up to 100 {server, tool, reason}). The draft is validated as if it replaced the same-named profile, or was appended under a placeholder name when it has none. Nothing is persisted, no record is written, no event is published and the FR-008a guard does not run. Administrators only.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.TryProfileRequest"}}},"description":"draft profile, query and optional limit (default 10, max 50)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Search result under the draft"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid draft; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Try a draft profile","tags":["profiles"]}},"/api/v1/profiles/{name}":{"delete":{"description":"Deletes a profile. 409 profile_in_use (with used_by) while clients or tokens point at it, unless reassign_to names another existing profile (every pin moves there) or force is set (pins are left dangling, which the resolver treats as deny-all). 409 profile_is_anonymous_profile whenever it is the anonymous_profile and reassign_to is absent, even with force. Both paths remove the name from every switchable_to. Guarded by FR-008a. Writes one ` + "`" + `delete` + "`" + ` record.","parameters":[{"description":"Profile name","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Another existing profile that takes over the pins","in":"query","name":"reassign_to","schema":{"type":"string"}},{"description":"Delete although in use, leaving pins dangling","in":"query","name":"force","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Deleted"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"reassign_to does not name another existing profile"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"profile not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ProfileConflictResponse"}}},"description":"profile_in_use, profile_is_anonymous_profile, or binding_bypassable_without_auth (BindingGuardResponse)"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a profile","tags":["profiles"]},"get":{"description":"Returns the ProfileView of the named profile. A caller that is not an administrator gets the same 404 for an unreachable profile as for an unknown one.","parameters":[{"description":"Profile name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"The profile"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"profile not found"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get one profile","tags":["profiles"]},"put":{"description":"Replaces the named profile with the body (a ProfileConfig whose name, when sent non-empty, must equal the path; 409 name_mismatch otherwise - a rename has its own route; an omitted or empty name takes the path's). A write whose only change is tools.classify is recorded as ` + "`" + `classify` + "`" + `. Guarded by FR-008a like a create.","parameters":[{"description":"Profile name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.ProfileConfig"}}},"description":"The profile","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Updated, with the validator's warnings"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"profile not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ProfileConflictResponse"}}},"description":"name_mismatch, or binding_bypassable_without_auth (BindingGuardResponse)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replace a profile","tags":["profiles"]}},"/api/v1/profiles/{name}/effective-tools":{"get":{"description":"One row per catalog tool with the verdict of the access chain (the same rendering GET /tools?profile= uses): server, tool, intrinsic_tier, profile_tier, access{visible, callable, reason} and classification_stale. With client=\u003cid\u003e the client's credential is evaluated UNDER this profile (\"what would Cursor get here\"). An administrator gets every row plus counts.callable, counts.by_reason and stale_classifications; every other caller gets only visible rows and counts{visible, hidden}, and client= and reason= are refused 403. An unreachable profile answers exactly like an unknown one.","parameters":[{"description":"Profile name","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Evaluate this client's credential under the profile (administrators only)","in":"query","name":"client","schema":{"type":"string"}},{"description":"Keep only this server's rows","in":"query","name":"server","schema":{"type":"string"}},{"description":"Keep only rows with this access reason (administrators only)","in":"query","name":"reason","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Effective tools"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"operation requires admin access"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"profile not found / client not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Effective tools of a profile","tags":["profiles"]}},"/api/v1/profiles/{name}/rename":{"post":{"description":"Renames a profile and moves every reference to it - token pins, client bindings, other profiles' switchable_to and the anonymous_profile - in one write (D19). Tokens move first, so a failure part-way never widens a scope. Guarded by FR-008a. Writes one ` + "`" + `rename` + "`" + ` record.","parameters":[{"description":"Profile name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.RenameProfileRequest"}}},"description":"new_name","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Renamed; moved lists the clients and tokens that followed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ClientBindingErrorResponse"}}},"description":"Invalid input; field names the offending input"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Administrator credentials required"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"profile not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ProfileConflictResponse"}}},"description":"profile_exists, or binding_bypassable_without_auth (BindingGuardResponse)"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Rename a profile","tags":["profiles"]}},"/api/v1/registries":{"get":{"description":"Retrieves list of all MCP server registries that can be browsed for discovering and installing new upstream servers. Includes registry metadata, server counts, and API endpoints.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetRegistriesResponse"}}},"description":"Registries retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to list registries"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List available MCP server registries","tags":["registries"]},"post":{"description":"Adds a generic modelcontextprotocol/registry v0.1 https endpoint as a custom registry (MCP-866). The source is always tagged custom/unverified, so every server discovered through it lands quarantined and can never skip quarantine.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddRegistrySourceRequest"}}},"description":"Registry source (https url + optional protocol/id/name)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source added"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/contracts.ErrorResponse"},{"$ref":"#/components/schemas/contracts.ErrorResponse"}]}}},"description":"Forbidden (agent tokens cannot mutate registries)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin | duplicate_registry"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a user-supplied registry source","tags":["registries"]}},"/api/v1/registries/{id}":{"delete":{"description":"Removes a custom/unverified registry previously added via add-source (MCP-1057). Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source removed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove a user-added custom registry source","tags":["registries"]},"put":{"description":"Updates a custom registry previously added via add-source (MCP-1072): name, url, servers-url. Empty fields are left unchanged. Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found; a non-https url yields invalid_registry_url. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.EditRegistrySourceRequest"}}},"description":"Fields to update (name/url/servers_url; empty = unchanged)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required | invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Edit a user-added custom registry source","tags":["registries"]}},"/api/v1/registries/{id}/refresh":{"post":{"description":"Invalidates the cached server lists for a registry so the next search re-fetches fresh data from the source (spec 070 FR-007). Returns how many cache entries were dropped.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.RefreshRegistryResponse"}}},"description":"Registry cache refreshed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh registry cache"}},"summary":"Refresh a registry's cached server list","tags":["registries"]}},"/api/v1/registries/{id}/servers":{"get":{"description":"Searches for MCP servers within a specific registry by keyword or tag. Returns server metadata including installation commands, source code URLs, and npm package information for easy discovery and installation.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Search query keyword","in":"query","name":"q","schema":{"type":"string"}},{"description":"Filter by tag","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Maximum number of results (default 10)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchRegistryServersResponse"}}},"description":"Servers retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to search servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search MCP servers in a registry","tags":["registries"]}},"/api/v1/registries/{id}/servers/{serverId}/add":{"post":{"description":"Resolves a registry server reference server-side, re-derives a validated config, and persists it quarantined (spec 070 keystone). The client never sends a config blob — command/args/url and the quarantine flag are derived from the registry entry, not the request.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Server ID within the registry","in":"path","name":"serverId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddFromRegistryRequest"}}},"description":"Optional overrides (name, env, enabled)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server added (quarantined)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"no_install_info | missing_required_input | duplicate_name"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot add servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found | server_not_found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add an upstream server from a registry reference","tags":["registries"]}},"/api/v1/review":{"get":{"description":"Returns one row per quarantined server or trusted server with pending or changed tools. Scoped callers see only servers they may enumerate.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Review queue"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to load review queue"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the server review queue","tags":["review"]}},"/api/v1/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints.\nrouting_mode is what /mcp is actually serving; pending_routing_mode carries a\nrestart-pending value persisted on disk (empty when there is none).\ntool_response_mode and direct_tool_response_mode report the two serialization axes, resolved.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/api/v1/secrets":{"post":{"description":"Stores a secret value in the operating system's secure keyring. The secret can then be referenced in configuration using ${keyring:secret-name} syntax. Automatically notifies runtime to restart affected servers.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored successfully with reference syntax"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload, missing name/value, or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to store secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Store a secret in OS keyring","tags":["secrets"]}},"/api/v1/secrets/{name}":{"delete":{"description":"Deletes a secret from the operating system's secure keyring. Automatically notifies runtime to restart affected servers. Only keyring type is supported for security.","parameters":[{"description":"Name of the secret to delete","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Secret type (only 'keyring' supported, defaults to 'keyring')","in":"query","name":"type","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret deleted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Missing secret name or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to delete secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a secret from OS keyring","tags":["secrets"]}},"/api/v1/security/bundle":{"get":{"description":"Reports the offline TPA signature corpus this install is serving: full SHA-256 digest, sequence, authority, trust epoch, signature verification, staleness, degraded coverage with its reason, rollback pin, and the last refused candidate. Admin only.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Bundle status (data is the BundleInfo object)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Admin credentials required"},"501":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Security scanner not configured"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the TPA signature bundle status","tags":["security"]}},"/api/v1/security/bundle/history":{"get":{"description":"Append-only list of bundle activations, oldest first, each with its full SHA-256 digest. It is the trustworthy source of the digest to name in a rollback. Admin only.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Activation history (data is an array of TPABundleActivation)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Admin credentials required"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"History could not be read"},"501":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Security scanner not configured"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the TPA signature bundle activation history","tags":["security"]}},"/api/v1/security/bundle/reload":{"post":{"description":"Re-runs the single-flight bundle pipeline against the configured source and current policy. A refused candidate leaves last-known-good serving and is reported as last_rejection in the returned status. Admin API key only.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Updated bundle status"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Admin credentials required"},"501":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Security scanner not configured"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reload the TPA signature bundle","tags":["security"]}},"/api/v1/security/bundle/rollback":{"post":{"description":"The one operator override of the downgrade watermark. Exactly one of embedded or path must be given, and digest must be the FULL 64-character lowercase hex SHA-256 of the target (the 12-character fingerprint is refused). On success the target serves and is pinned. A refusal (for example rollback_target_mismatch) returns 409 with the rejection reason and last-known-good keeps serving. Admin API key only; agent tokens are refused.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.bundleRollbackRequest"}}},"description":"Rollback target","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Updated bundle status"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Usage error: both or neither of embedded/path, or digest is not 64 lowercase hex"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Admin credentials required"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.BundleRollbackRefusedResponse"}}},"description":"Rollback refused; the response carries the rejection reason"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal error"},"501":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Security scanner not configured"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Roll the TPA signature bundle back to a named artifact","tags":["security"]}},"/api/v1/security/bundle/unpin":{"post":{"description":"Clears the rollback pin and re-validates the serving bundle under the normal downgrade watermark, so a rolled-back target below the watermark no longer serves. A quiet no-op when nothing is pinned. Admin API key only.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Updated bundle status"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Admin credentials required"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"The unpin could not be recorded"},"501":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Security scanner not configured"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Clear the TPA signature bundle rollback pin","tags":["security"]}},"/api/v1/servers":{"get":{"description":"Get a list of all configured upstream MCP servers with their connection status and statistics","parameters":[{"description":"Restrict to the profile's effective servers; tool_count becomes the number of that server's tools visible under the profile (Spec 108 FR-032)","in":"query","name":"profile","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unsupported scope filter (client/token), or '-' on a server filter"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown or unreachable profile"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List all upstream MCP servers","tags":["servers"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security. Isolation: ` + "`" + `isolation.enabled` + "`" + ` is READ-ONLY (it reports the effective state on reads) and is rejected with 400; set the per-server override via ` + "`" + `isolation.enabled_override` + "`" + ` (true | false | null to clear, omit to leave unchanged). An unrecognized ` + "`" + `isolation.mode_override` + "`" + ` is rejected with 400.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Conflict - server with this name already exists"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a new upstream server","tags":["servers"]}},"/api/v1/servers/disable_all":{"post":{"description":"Disable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk disable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable all servers","tags":["servers"]}},"/api/v1/servers/enable_all":{"post":{"description":"Enable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk enable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable all servers","tags":["servers"]}},"/api/v1/servers/import":{"post":{"description":"Import MCP server configurations from a Claude Desktop, Claude Code, Cursor IDE, Codex CLI, or Gemini CLI configuration file","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}},{"description":"Force format (claude-desktop, claude-code, cursor, codex, gemini)","in":"query","name":"format","schema":{"type":"string"}},{"description":"Comma-separated list of server names to import","in":"query","name":"server_names","schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"file"}}},"description":"Configuration file to import","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid file or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from uploaded configuration file","tags":["servers"]}},"/api/v1/servers/import/json":{"post":{"description":"Import MCP server configurations from raw JSON or TOML content (useful for pasting configurations)","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportRequest"}}},"description":"Import request with content","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid content or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from JSON/TOML content","tags":["servers"]}},"/api/v1/servers/import/path":{"post":{"description":"Import MCP server configurations by reading a file from the server's filesystem. A preview (preview=true) of a file with no MCP servers (empty, {}, or no/empty server map) answers 200 with an empty imported list; an apply of such a file and a malformed file still answer 400.","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportFromPathRequest"}}},"description":"Import request with file path","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid path or format"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"File not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from a file path","tags":["servers"]}},"/api/v1/servers/import/paths":{"get":{"description":"Returns well-known configuration file paths for supported formats with existence check","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPathsResponse"}}},"description":"Canonical config paths"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get canonical config file paths","tags":["servers"]}},"/api/v1/servers/reconnect":{"post":{"description":"Force reconnection to all upstream MCP servers","parameters":[{"description":"Reason for reconnection","in":"query","name":"reason","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"All servers reconnected successfully"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reconnect all servers","tags":["servers"]}},"/api/v1/servers/restart_all":{"post":{"description":"Restart all configured upstream MCP servers sequentially with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk restart results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart all servers","tags":["servers"]}},"/api/v1/servers/{id}":{"delete":{"description":"Remove an MCP upstream server from the configuration. This stops the server if running and removes it from config.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server removed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove an upstream server","tags":["servers"]},"patch":{"description":"Update specific fields of an existing upstream MCP server configuration. Isolation: ` + "`" + `isolation.enabled` + "`" + ` is READ-ONLY (it reports the effective state on reads) and is rejected with 400; set the per-server override via ` + "`" + `isolation.enabled_override` + "`" + ` (true | false | null to clear, omit to leave unchanged). An unrecognized ` + "`" + `isolation.mode_override` + "`" + ` is rejected with 400.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Fields to update (all optional)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server updated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - no fields or invalid body"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/config-to-secret":{"post":{"description":"Atomically reads the real value from the server config, stores it in the OS keyring, and rewrites the config field to ` + "`" + `${keyring:\u003cname\u003e}` + "`" + `. Unblocks the UI's Convert-to-secret affordance for values the API redacts on the read path.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored, config updated with reference"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad scope/key/secret_name, or value is already a reference / empty"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server or key not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver or config update failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Convert a header / env value to a keyring secret","tags":["servers"]}},"/api/v1/servers/{id}/disable":{"post":{"description":"Disable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server disabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/discover-tools":{"post":{"description":"Manually trigger tool discovery and indexing for a specific upstream MCP server. This forces an immediate refresh of the server's tool cache.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool discovery triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot discover tools)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to discover tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Discover tools for a specific server","tags":["servers"]}},"/api/v1/servers/{id}/enable":{"post":{"description":"Enable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server enabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/login":{"post":{"description":"Initiate OAuth authentication flow for a specific upstream MCP server. Returns structured OAuth start response with correlation ID for tracking.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthStartResponse"}}},"description":"OAuth login initiated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthFlowError"}}},"description":"OAuth error (client_id required, DCR failed, etc.)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Trigger OAuth login for server","tags":["servers"]}},"/api/v1/servers/{id}/logout":{"post":{"description":"Clear OAuth authentication token and disconnect a specific upstream MCP server. The server will need to re-authenticate before tools can be used again.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"OAuth logout completed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled or read-only mode)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Clear OAuth token and disconnect server","tags":["servers"]}},"/api/v1/servers/{id}/logs":{"get":{"description":"Retrieve log entries for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Number of log lines to retrieve","in":"query","name":"tail","schema":{"default":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerLogsResponse"}}},"description":"Server logs retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server logs","tags":["servers"]}},"/api/v1/servers/{id}/quarantine":{"post":{"description":"Place a specific upstream MCP server in quarantine to prevent tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server quarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Quarantine a server","tags":["servers"]}},"/api/v1/servers/{id}/refresh":{"post":{"description":"Re-discover and re-index a specific upstream MCP server's tools without changing any security state. Alias of discover-tools, named for the upstream_servers 'refresh' operation; use it to make just-approved tools searchable immediately.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool refresh triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot refresh)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Refresh a server's tools","tags":["servers"]}},"/api/v1/servers/{id}/restart":{"post":{"description":"Restart the connection to a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server restarted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/review":{"get":{"description":"Returns redacted server configuration, captured tool definitions, annotations, scan verdicts, and diffs. Scoped callers receive the same 404 for invisible and missing servers.","parameters":[{"description":"Server name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server review"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to load server review"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get review details for a server","tags":["review"]}},"/api/v1/servers/{id}/security/approve":{"post":{"description":"Saves the integrity baseline and optionally blocks selected tools atomically before unquarantining the server. With expected_hashes (tool name -\u003e the review's current_hash; {} for an empty review) the approval is bound to the reviewed definitions: when a tool the approval would make reachable (pending, or approved and enabled, minus block) changed or appeared since the review, or a block target no longer exists, the response is 409 review_out_of_date, nothing is written and the server stays quarantined. Tools listed after the approval wait for review.","parameters":[{"description":"Server name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.securityApproveRequest"}}},"description":"Force approval and tool names to keep disabled"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server approved"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid request or unknown tool block target"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not configured"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.StaleReviewErrorResponse"}}},"description":"Dangerous scan verdict requires force, approval failed, the review is out of date (error_code review_out_of_date), or server_replaced (the server was removed or re-added since expected_incarnation)"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Approve a server after security review","tags":["security"]}},"/api/v1/servers/{id}/tool-calls":{"get":{"description":"Retrieves tool call history filtered by upstream server ID. Returns recent tool executions for the specified server including timestamps, arguments, results, and errors. Useful for server-specific debugging and monitoring.","parameters":[{"description":"Upstream server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolCallsResponse"}}},"description":"Server tool calls retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get server tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history for specific server","tags":["tool-calls"]}},"/api/v1/servers/{id}/tools":{"get":{"description":"Retrieve all available tools for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolsResponse"}}},"description":"Server tools retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"server_replaced: the server kept being removed or replaced while its tools were read"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/approve":{"post":{"description":"Approves the given pending/changed tools (or all of them with approve_all). With expected_hashes (tool name -\u003e the review's current_hash) the approval is bound to the reviewed definitions: when a requested tool (approve_all: any pending or changed tool) no longer matches, the response is 409 review_out_of_date and nothing is approved.","parameters":[{"description":"Server name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.approveToolsRequest"}}},"description":"Tools to approve, or approve_all, with an optional review binding","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Approval outcome"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid request"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.StaleReviewErrorResponse"}}},"description":"The review is out of date; nothing was approved"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Approve tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/block":{"post":{"description":"Atomically approves AND disables the given tools (or all pending/changed tools when block_all=true) for a server. The approve and disable land in a single write per tool, so a tool is never left in the approved+enabled state. The \"blocked\" field counts tools actually blocked. An optional expected_incarnation binds the write to the reviewed server (Spec 116): a remove and re-add since then answers 409 server_replaced and writes nothing.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Block result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"server_replaced: the server was removed or re-added since expected_incarnation"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Block (approve+disable) tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/disable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.expectedIncarnationBody"}}},"description":"Optional incarnation of the reviewed server (Spec 116)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"server_replaced: the server was removed or re-added since expected_incarnation"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/enable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.expectedIncarnationBody"}}},"description":"Optional incarnation of the reviewed server (Spec 116)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"server_replaced: the server was removed or re-added since expected_incarnation"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/unquarantine":{"post":{"description":"Remove a specific upstream MCP server from quarantine to allow tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.expectedIncarnationBody"}}},"description":"Optional incarnation of the reviewed server (Spec 116)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server unquarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"server_replaced: the server was removed or re-added since expected_incarnation"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Unquarantine a server","tags":["servers"]}},"/api/v1/sessions":{"get":{"description":"Retrieves paginated list of active and recent MCP client sessions. Each session represents a connection from an MCP client to MCPProxy, tracking initialization time, tool calls, and connection status.","parameters":[{"description":"Maximum number of sessions to return (1-100, default 10)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of sessions to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter by session status","in":"query","name":"status","schema":{"enum":["active","closed"],"type":"string"}},{"description":"Filter by the session's latest effective profile; - selects sessions with none (Spec 108)","in":"query","name":"profile","schema":{"type":"string"}},{"description":"Filter by the client id the session's credential is bound to; - selects sessions with none (Spec 108)","in":"query","name":"client","schema":{"type":"string"}},{"description":"Filter by the token name the session initialized with; - selects sessions with none (Spec 108)","in":"query","name":"token","schema":{"type":"string"}},{"description":"Alias of token","in":"query","name":"agent","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionsResponse"}}},"description":"Sessions retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid status filter, token and agent naming different tokens, or client_name (not supported here; filter by client)"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read MCP session history"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get sessions"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get active MCP sessions","tags":["sessions"]}},"/api/v1/sessions/{id}":{"get":{"description":"Retrieves detailed information about a specific MCP client session including initialization parameters, connection status, tool call count, and activity timestamps.","parameters":[{"description":"Session ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionDetailResponse"}}},"description":"Session details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read MCP session history"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get MCP session details by ID","tags":["sessions"]}},"/api/v1/stats/tokens":{"get":{"description":"Retrieve token savings statistics across all servers and sessions","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Token statistics"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read deployment-wide token statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get token savings statistics","tags":["stats"]}},"/api/v1/status":{"get":{"description":"Get comprehensive server status including running state, listen address, upstream statistics, and timestamp\ntelemetry (admin only): effective telemetry state {enabled, source: env|config|default, disabled_by}","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server status","tags":["status"]}},"/api/v1/telemetry/payload":{"get":{"description":"Render the exact JSON heartbeat payload that mcpproxy would next send to the telemetry endpoint, without making a network call. Counters in the payload reflect the current in-memory state. Spec 042.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Telemetry heartbeat payload"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read the deployment telemetry payload"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Telemetry service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview next telemetry heartbeat payload","tags":["telemetry"]}},"/api/v1/telemetry/update-failure":{"post":{"description":"Records one terminal update-session failure, identified only by its\nstage (appcast, download, install, other). The body carries no error\ntext, URL, or version — the stage is the only value transmitted.\nReturns 204 both when the occurrence was durably persisted and when\ntelemetry is inactive at event time (config opt-out, environment\nopt-out, CI, or dev build), in which case nothing is recorded.\nCallers cannot and need not distinguish the two.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UpdateFailureRequest"}}},"description":"Update failure stage","required":true},"responses":{"204":{"description":"Accepted (recorded, or a deliberate no-op while telemetry is inactive)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Malformed body, unknown field, trailing value, or stage outside the closed set"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Persistence failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Record a desktop auto-update failure occurrence (Spec 095)","tags":["telemetry"]}},"/api/v1/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools":{"get":{"description":"Consolidated, read-only listing of all tools from every configured server (including disabled servers and disabled/config-denied tools), enriched with approval state and 30-day usage. Backs the global Tools page and the CLI global ` + "`" + `tools list` + "`" + ` (spec 050, issue #437).","parameters":[{"description":"View-as (administrator only): the tools this client would see, each with its access verdict {visible, callable, reason} and profile_tier (Spec 108 FR-032)","in":"query","name":"client","schema":{"type":"string"}},{"description":"View-as: the tools this profile would expose. An administrator gets every row with a verdict; any other caller gets only the visible rows plus counts {visible, hidden} (Spec 108 FR-032)","in":"query","name":"profile","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GlobalToolsResponse"}}},"description":"All tools across all servers"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unsupported scope filter (token), both client and profile, or '-' on a tools filter"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"client= requires administrator credentials"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown or unreachable client / profile"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Could not enumerate servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List every tool across all servers","tags":["tools"]}},"/api/v1/tools/call":{"post":{"description":"Execute a tool on an upstream MCP server (wrapper around MCP tool calls)","requestBody":{"content":{"application/json":{"schema":{"properties":{"arguments":{"type":"object"},"tool_name":{"type":"string"}},"type":"object"}}},"description":"Tool call request with tool name and arguments","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Tool call result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (invalid payload or missing tool name)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}}, "openapi": "3.1.0" }` diff --git a/oas/swagger.yaml b/oas/swagger.yaml index ff8263bee..602aca39e 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -963,6 +963,13 @@ components: type: string integrity_check_on_restart: type: boolean + require_signed_bundle: + description: |- + RequireSignedBundle makes the detached signature sidecar mandatory for + file-dropped bundles (FR-007). Default false (maintainer decision D2): + existing unsigned drops keep working, but an unsigned external bundle + always runs with DEGRADED coverage (scan-mode auto-approval suspended). + type: boolean runtime_read_only: type: boolean runtime_tmpfs_size: @@ -1022,6 +1029,14 @@ components: type: boolean scanner_registry_url: type: string + tpa_active_root: + description: |- + TPAActiveRoot declares which signing authority's bundles may activate + (FR-004b): "publisher" (default, the keys embedded in this binary) or + "operator" (TPAOperatorSigners). A bundle verified by the other root is + refused as authority_not_active, so whoever can write the bundle path + cannot choose the authority. + type: string tpa_bundle_path: description: |- TPABundlePath is the filesystem path to the tpa-db scanner-bundle.json @@ -1037,6 +1052,30 @@ components: fail-empty); the reason is logged and surfaced in the security overview's signature_bundle.load_error. type: string + tpa_operator_baseline: + description: |- + TPAOperatorBaseline seeds the operator authority's anti-downgrade + watermark (FR-009b): an operator-signed bundle must carry a sequence + ABOVE it (a rollback target may sit at it). It survives a data-directory + wipe because it lives here. Absent means the operator authority has no + anti-downgrade floor and an active operator bundle runs DEGRADED. + type: integer + tpa_operator_epoch: + description: |- + TPAOperatorEpoch is the operator authority's trust epoch (FR-005a). + Bumping it (after an operator key compromise) re-baselines the operator + watermark and retires prior-epoch pins. Absent means 0. + type: integer + tpa_operator_signers: + description: |- + TPAOperatorSigners lists operator Ed25519 public keys, each the standard + base64 encoding of the 32 raw key bytes (FR-004). Their key ids are the + lowercase-hex SHA-256 of those bytes. Malformed or duplicate entries are + rejected on write and dropped (with a log) on load. + items: + type: string + type: array + uniqueItems: false type: object config.SensitiveDataDetectionConfig: description: Sensitive data detection settings (Spec 026) @@ -3807,6 +3846,19 @@ components: error: type: string type: object + httpapi.BundleRollbackRefusedResponse: + properties: + error: + type: string + error_code: + type: string + rejection: + $ref: '#/components/schemas/scanner.BundleRejection' + request_id: + type: string + success: + type: boolean + type: object httpapi.CanonicalConfigPath: properties: description: @@ -4689,6 +4741,19 @@ components: type: array uniqueItems: false type: object + httpapi.bundleRollbackRequest: + properties: + digest: + description: Digest is the full 64-character lowercase hex SHA-256 of the + target. + type: string + embedded: + description: Embedded rolls back to the corpus compiled into this binary. + type: boolean + path: + description: Path is where to find the target bundle (a hint only). + type: string + type: object httpapi.clientPresence: properties: active_sessions: @@ -5179,6 +5244,20 @@ components: target: type: string type: object + scanner.BundleRejection: + properties: + at: + type: string + detail: + description: |- + Detail is a short human string (which ceiling, which key id, which + epoch, the bundlesig contract code). Never parsed. + type: string + reason: + description: Reason is one of the Reject* constants; the only field to branch + on. + type: string + type: object secureenv.EnvConfig: description: Environment configuration for secure variable filtering properties: @@ -8798,6 +8877,198 @@ paths: summary: Delete a secret from OS keyring tags: - secrets + /api/v1/security/bundle: + get: + description: 'Reports the offline TPA signature corpus this install is serving: + full SHA-256 digest, sequence, authority, trust epoch, signature verification, + staleness, degraded coverage with its reason, rollback pin, and the last refused + candidate. Admin only.' + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.SuccessResponse' + description: Bundle status (data is the BundleInfo object) + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Admin credentials required + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Security scanner not configured + security: + - ApiKeyAuth: [] + - ApiKeyQuery: [] + summary: Get the TPA signature bundle status + tags: + - security + /api/v1/security/bundle/history: + get: + description: Append-only list of bundle activations, oldest first, each with + its full SHA-256 digest. It is the trustworthy source of the digest to name + in a rollback. Admin only. + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.SuccessResponse' + description: Activation history (data is an array of TPABundleActivation) + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Admin credentials required + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: History could not be read + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Security scanner not configured + security: + - ApiKeyAuth: [] + - ApiKeyQuery: [] + summary: Get the TPA signature bundle activation history + tags: + - security + /api/v1/security/bundle/reload: + post: + description: Re-runs the single-flight bundle pipeline against the configured + source and current policy. A refused candidate leaves last-known-good serving + and is reported as last_rejection in the returned status. Admin API key only. + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.SuccessResponse' + description: Updated bundle status + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Admin credentials required + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Security scanner not configured + security: + - ApiKeyAuth: [] + - ApiKeyQuery: [] + summary: Reload the TPA signature bundle + tags: + - security + /api/v1/security/bundle/rollback: + post: + description: The one operator override of the downgrade watermark. Exactly one + of embedded or path must be given, and digest must be the FULL 64-character + lowercase hex SHA-256 of the target (the 12-character fingerprint is refused). + On success the target serves and is pinned. A refusal (for example rollback_target_mismatch) + returns 409 with the rejection reason and last-known-good keeps serving. Admin + API key only; agent tokens are refused. + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/httpapi.bundleRollbackRequest' + description: Rollback target + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.SuccessResponse' + description: Updated bundle status + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: 'Usage error: both or neither of embedded/path, or digest is + not 64 lowercase hex' + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Admin credentials required + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/httpapi.BundleRollbackRefusedResponse' + description: Rollback refused; the response carries the rejection reason + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Internal error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Security scanner not configured + security: + - ApiKeyAuth: [] + - ApiKeyQuery: [] + summary: Roll the TPA signature bundle back to a named artifact + tags: + - security + /api/v1/security/bundle/unpin: + post: + description: Clears the rollback pin and re-validates the serving bundle under + the normal downgrade watermark, so a rolled-back target below the watermark + no longer serves. A quiet no-op when nothing is pinned. Admin API key only. + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.SuccessResponse' + description: Updated bundle status + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Admin credentials required + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: The unpin could not be recorded + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Security scanner not configured + security: + - ApiKeyAuth: [] + - ApiKeyQuery: [] + summary: Clear the TPA signature bundle rollback pin + tags: + - security /api/v1/servers: get: description: Get a list of all configured upstream MCP servers with their connection From af3176f6db2b8488669e985174f5c930ef68919d Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 14:20:28 +0300 Subject: [PATCH 14/35] feat(security): Spec 101 US1 harden rollback pin, LKG recovery and stage order Fixes from the Wave 2 independent review: - a binding rollback pin now governs unsigned candidates, and the pin refuses the watermark-setting release too, so an operator rollback is not reverted by the next reload - a transient last-known-good read error keeps the active record and its files instead of reconciling them away - while the anti-downgrade state cannot be loaded a new external activation is refused (state_commit_failed) - a malformed sidecar is reported ahead of an oversized bundle (FR-008 stage order) - the embedded default without a sequence uses the publisher baseline, so a non-zero fallback baseline does not degrade a fresh install Related #938 --- .../scanner/tpa_bundle_review_test.go | 155 ++++++++++++++++++ .../scanner/tpa_bundle_review_unix_test.go | 45 +++++ .../security/scanner/tpa_bundle_runtime.go | 57 +++++-- .../scanner/tpa_bundle_runtime_test.go | 19 --- .../scanner/tpa_bundle_state_logic.go | 6 +- internal/security/scanner/tpa_candidate.go | 39 ++++- 6 files changed, 281 insertions(+), 40 deletions(-) create mode 100644 internal/security/scanner/tpa_bundle_review_test.go create mode 100644 internal/security/scanner/tpa_bundle_review_unix_test.go diff --git a/internal/security/scanner/tpa_bundle_review_test.go b/internal/security/scanner/tpa_bundle_review_test.go new file mode 100644 index 000000000..f7d3193fe --- /dev/null +++ b/internal/security/scanner/tpa_bundle_review_test.go @@ -0,0 +1,155 @@ +package scanner + +import ( + "bytes" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Regression tests for the Wave 2 judge's findings (spec 101 FR-010, FR-008, +// FR-011a, FR-007/FR-009a). + +// FR-010 / SC-002: an explicit rollback away from an UNSIGNED external bundle +// must not be undone by the next refresh. Unsigned releases are not deny-listed +// (they carry no trustworthy sequence), so the pin has to govern them. +func TestRollback_UnsignedReleaseRolledAwayIsNotReactivated(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + u := unsignedCandidate(t, bundleOpts{Tag: "U"}) + f.activate(u) + + info, err := RollbackBundle(BundleRollbackRequest{Embedded: true, Digest: embeddedDigest()}, f.log) + require.NoError(t, err) + require.Equal(t, BundleSourceEmbedded, info.Source) + require.True(t, info.Pinned) + require.True(t, info.Rollback) + + // The path still holds U; a reload (or any config hot-reload) must not + // serve it again. + info = ReloadBundle(f.log) + assert.Equal(t, BundleSourceEmbedded, info.Source, "the operator's rollback is not silently reverted") + assert.Equal(t, embeddedDigest(), info.FullDigest) + assert.True(t, info.Pinned) + assert.True(t, info.Rollback) + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectDowngrade, info.LastRejection.Reason) + assert.Len(t, f.store.historyRows(), 2, "a refusal is not an activation") + + f.restart() + info = f.run() + assert.Equal(t, BundleSourceEmbedded, info.Source, "and a restart does not revert it either") + assert.Len(t, f.store.historyRows(), 2) + + // Unpinning is the operator's explicit way back. + info, err = UnpinBundle(f.log) + require.NoError(t, err) + assert.False(t, info.Pinned) + assert.Equal(t, digestOf(u.Bundle), info.FullDigest) +} + +// A rollback to a named unsigned target must still work while another pin binds. +func TestRollback_ToUnsignedTargetWhileAnotherPinBinds(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + f.activate(unsignedCandidate(t, bundleOpts{Tag: "U1"})) + _, err := RollbackBundle(BundleRollbackRequest{Embedded: true, Digest: embeddedDigest()}, f.log) + require.NoError(t, err) + + u2 := unsignedCandidate(t, bundleOpts{Tag: "U2"}) + u2Path := f.drop + "/u2.json" + f.putAt(u2Path, u2) + info, err := RollbackBundle(BundleRollbackRequest{Path: u2Path, Digest: digestOf(u2.Bundle)}, f.log) + require.NoError(t, err) + assert.Equal(t, digestOf(u2.Bundle), info.FullDigest) + assert.True(t, info.Pinned) +} + +// FR-010: while a pin to a different target binds, even the release that set the +// watermark is refused. +func TestRollback_PinRefusesTheWatermarkSetterToo(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + x := signedCandidate(t, keyPublisher, bundleOpts{Seq: 10, Tag: "X"}) + f.activate(x) + + // The operator clears the configured path (embedded serves) and rolls back to + // the embedded default: nothing external was serving, so nothing is + // deny-listed. + f.sec.TPABundlePath = "" + info := f.run() + require.Equal(t, BundleSourceEmbedded, info.Source) + _, err := RollbackBundle(BundleRollbackRequest{Embedded: true, Digest: embeddedDigest()}, f.log) + require.NoError(t, err) + + f.sec.TPABundlePath = f.path + info = f.run() + assert.Equal(t, BundleSourceEmbedded, info.Source, "the pin binds; the setter is not special") + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectDowngrade, info.LastRejection.Reason) + assert.True(t, info.Pinned) + + // A genuinely newer release still supersedes the pin. + info = f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 11, Tag: "newer"})) + assert.False(t, info.Pinned) +} + +// FR-011a: while the state cannot be loaded a NEW external activation is refused +// (activating without a durable watermark reopens the replay window). +func TestRuntime_StateLoadFailureRefusesNewActivation(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + f.store.failLoad = errors.New("bolt: timeout") + f.put(signedCandidate(t, keyPublisher, bundleOpts{Seq: 5})) + + info := f.run() + + assert.Equal(t, BundleSourceEmbedded, info.Source) + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectStateCommitFailed, info.LastRejection.Reason) + assert.Zero(t, f.store.commitCount()) + assert.Empty(t, f.lkgFiles()) + + // Once the store recovers the activation is recorded. + f.store.failLoad = nil + info = f.run() + assert.Equal(t, "5", info.Sequence) + assert.Equal(t, 1, f.store.commitCount()) + assert.Len(t, f.store.historyRows(), 1) +} + +// FR-008 stage order: sidecar parse precedes the bundle byte ceiling. +func TestRuntime_MalformedSidecarOutranksOversizedBundle(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + prev := BundleStatus() + chk := servingCheck() + require.NoError(t, writeFileForTest(f.path, bytes.Repeat([]byte{'x'}, MaxBundleBytes+1))) + require.NoError(t, writeFileForTest(f.path+".sig", bytes.Repeat([]byte{'g'}, 4096))) + + info := f.run() + + f.requireRejected(info, RejectSidecarInvalid, prev.FullDigest, chk) +} + +// An oversized bundle with a WELL-FORMED sidecar is still a bundle ceiling. +func TestRuntime_OversizedBundleWithValidSidecarIsCeiling(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + c := signedCandidate(t, keyPublisher, bundleOpts{Seq: 5}) + require.NoError(t, writeFileForTest(f.path, bytes.Repeat([]byte{'x'}, MaxBundleBytes+1))) + require.NoError(t, writeFileForTest(f.path+".sig", c.Sidecar)) + + info := f.run() + + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectCeilingExceeded, info.LastRejection.Reason) +} + +// FR-007 / FR-009a: a non-zero publisher fallback baseline must not make the +// embedded default degraded on an ordinary fresh install. +func TestRuntime_EmbeddedWithoutSequenceIsTheFallbackBaseline(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + publisherFallbackBaseline = 5 // restored by withTrustAnchors' cleanup + + info := f.run() + + assert.Equal(t, BundleSourceEmbedded, info.Source) + assert.False(t, info.CoverageDegraded, "reason %q", info.DegradedReason) +} diff --git a/internal/security/scanner/tpa_bundle_review_unix_test.go b/internal/security/scanner/tpa_bundle_review_unix_test.go new file mode 100644 index 000000000..ad001109b --- /dev/null +++ b/internal/security/scanner/tpa_bundle_review_unix_test.go @@ -0,0 +1,45 @@ +//go:build unix + +package scanner + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// FR-011a: a TRANSIENT last-known-good read failure (EACCES here) must not +// forget the recoverable LKG. Embedded serves for that run, but the committed +// active record and its files stay, so the release comes back once the read works. +func TestRuntime_TransientLKGReadErrorKeepsTheRecord(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("chmod 000 does not deny root") + } + f := newRTFixture(t, rtOpts{}) + x := signedCandidate(t, keyPublisher, bundleOpts{Seq: 10, Tag: "X"}) + f.activate(x) + files := f.lkgFiles() + require.NotEmpty(t, files) + + f.restart() + f.put(signedCandidate(t, keyPublisher, bundleOpts{Seq: 5, Tag: "older"})) // refused: downgrade + lkg := filepath.Join(f.dir, "tpa-lkg", digestOf(x.Bundle)+".json") + require.NoError(t, os.Chmod(lkg, 0o000)) + t.Cleanup(func() { _ = os.Chmod(lkg, 0o600) }) + + info := f.run() + + assert.Equal(t, BundleSourceEmbedded, info.Source, "embedded serves while the LKG is unreadable") + st := f.store.persisted(t) + require.NotNil(t, st.Active, "a transient read error does not erase the active record") + assert.Equal(t, digestOf(x.Bundle), st.Active.FullDigest) + assert.ElementsMatch(t, files, f.lkgFiles(), "the LKG files are not reconciled away") + + require.NoError(t, os.Chmod(lkg, 0o600)) + info = f.run() + assert.Equal(t, digestOf(x.Bundle), info.FullDigest, "the release is restored from its LKG") + assert.Len(t, f.store.historyRows(), 1, "restoring from the LKG is not a new activation") +} diff --git a/internal/security/scanner/tpa_bundle_runtime.go b/internal/security/scanner/tpa_bundle_runtime.go index 144ca46cc..2350cc0bb 100644 --- a/internal/security/scanner/tpa_bundle_runtime.go +++ b/internal/security/scanner/tpa_bundle_runtime.go @@ -13,6 +13,7 @@ import ( "go.uber.org/zap" "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/security/bundlesig" ) // The serialized bundle runtime (spec 101 FR-008, FR-009, FR-011a; design §2/§6). @@ -237,6 +238,11 @@ type selection struct { // longer passes validation under the current policy and state. deactivated *BundleRejection deactivatedDigest string + // keepActive is set when the recorded active release could not be restored + // for a TRANSIENT reason (an I/O error or timeout reading its last-known-good + // files): the embedded default serves for this run, but the committed active + // record and its files are kept so the release comes back once reads work. + keepActive bool } // runLocked is one pipeline run (design §6). The caller holds rt.mu. @@ -272,7 +278,7 @@ func (rt *bundleRuntime) runLocked(policy *bundlePolicy, log *zap.Logger, opts r activation = true sel.serving.acc.Meta.GeneratedAt = sel.serving.generatedAt st, row = applyActivation(nst, sel.serving.acc, sel.serving.cand, now) - case sel.serving != nil && sel.serving.embedded && nst.Active != nil: + case sel.serving != nil && sel.serving.embedded && nst.Active != nil && !sel.keepActive: st = cloneState(nst) st.Active = nil needCommit = true @@ -291,7 +297,7 @@ func (rt *bundleRuntime) runLocked(policy *bundlePolicy, log *zap.Logger, opts r failed := sel sel = selection{attempt: rej, attemptDigest: failed.serving.digest} rt.fallbackLocked(policy, nst, &sel, log) - if sel.serving != nil && sel.serving.embedded && nst.Active != nil { + if sel.serving != nil && sel.serving.embedded && nst.Active != nil && !sel.keepActive { st = cloneState(nst) st.Active = nil if perr := rt.persistLocked(st, nil, nil, log); perr != nil { @@ -467,11 +473,14 @@ func (rt *bundleRuntime) fallbackLocked(policy *bundlePolicy, nst *TPABundleStat zap.String("reason", rej.Reason), zap.String("detail", rej.Detail)) case nst.Active != nil && rt.dataDir != "" && !rt.loadFailed: - if sv, rej := rt.restoreFromLKGLocked(policy, nst, log); sv != nil { + sv, rej, transient := rt.restoreFromLKGLocked(policy, nst, log) + if sv != nil { sel.serving = sv return - } else if rej != nil { + } + if rej != nil { sel.deactivated, sel.deactivatedDigest = rej, nst.Active.FullDigest + sel.keepActive = transient } } } @@ -487,36 +496,42 @@ func (rt *bundleRuntime) fallbackLocked(policy *bundlePolicy, nst *TPABundleStat // restoreFromLKGLocked re-validates the state's Active record from its LKG // files. A missing or substituted file, or a record that no longer validates, -// yields a rejection and no serving bundle. -func (rt *bundleRuntime) restoreFromLKGLocked(policy *bundlePolicy, nst *TPABundleState, log *zap.Logger) (*servingBundle, *BundleRejection) { +// yields a rejection and no serving bundle. The third result is true when the +// failure is transient (an I/O error or timeout, not proof the release is gone): +// the caller must then keep the active record and its files. +func (rt *bundleRuntime) restoreFromLKGLocked(policy *bundlePolicy, nst *TPABundleState, log *zap.Logger) (*servingBundle, *BundleRejection, bool) { rec := nst.Active ctx, cancel := context.WithTimeout(context.Background(), bundleReadBudget) defer cancel() cand, err := lkgRead(ctx, rt.dataDir, rec) if err != nil { rej := &BundleRejection{At: time.Now()} + transient := false var ce *candidateReadError switch { case errors.Is(err, errLKGSubstituted): rej.Reason, rej.Detail = RejectTamper, "last-known-good file does not match its recorded digest "+shortDigest(rec.FullDigest) case errors.As(err, &ce): rej.Reason, rej.Detail = ce.Reason, "last-known-good: "+ce.Detail + transient = !ce.NotExist && (ce.Reason == RejectReadError || ce.Reason == RejectReadTimeout) default: rej.Reason, rej.Detail = RejectReadError, "last-known-good: "+err.Error() + transient = true } log.Warn("recorded last-known-good TPA bundle is unavailable; falling back to the embedded default", - zap.String("full_digest", rec.FullDigest), zap.String("reason", rej.Reason), zap.String("detail", rej.Detail)) - return nil, rej + zap.String("full_digest", rec.FullDigest), zap.String("reason", rej.Reason), zap.String("detail", rej.Detail), + zap.Bool("record_kept", transient)) + return nil, rej, transient } acc, rej := evaluateCandidate(cand, evalEnv{Policy: policy, State: nst, Mode: evalLKG}) if rej != nil { log.Warn("recorded last-known-good TPA bundle no longer passes validation; falling back to the embedded default", zap.String("full_digest", rec.FullDigest), zap.String("reason", rej.Reason), zap.String("detail", rej.Detail)) - return nil, rej + return nil, rej, false } sv := newExternalServing(cand, acc) sv.generatedAt = rec.GeneratedAt - return sv, nil + return sv, nil, false } // persistLocked makes one transition durable and then publishes nothing: LKG @@ -524,6 +539,14 @@ func (rt *bundleRuntime) restoreFromLKGLocked(policy *bundlePolicy, nst *TPABund // state transaction (state + history row), with the crash seams between. On // success it updates the state mirror and reconciles the LKG directory. func (rt *bundleRuntime) persistLocked(st *TPABundleState, row *TPABundleActivation, sv *servingBundle, log *zap.Logger) error { + if rt.loadFailed && row != nil { + // A NEW activation needs a durable watermark and ratchet. With the state + // unreadable nothing can be recorded, and activating anyway would let a + // later restart (with a readable store holding an older watermark) accept + // an older release than the one that had been serving. Refuse, as for a + // failed commit. + return errors.New("the anti-downgrade state could not be loaded, so the activation cannot be recorded") + } if sv != nil && !sv.embedded && rt.dataDir != "" && !rt.loadFailed { if err := lkgWrite(rt.dataDir, sv.digest, sv.cand.Bundle, sv.cand.Sidecar, sv.cand.SidecarPresent); err != nil { rt.reconcileLocked(rt.state, log) @@ -662,7 +685,10 @@ func (rt *bundleRuntime) buildInfoLocked(policy *bundlePolicy, st *TPABundleStat info.Path = sv.cand.Path } - embeddedSeq := uint64(0) + // The embedded default IS the publisher baseline: when its manifest carries + // no sequence the baseline is the declared fallback, and the default must + // not read as "below" the very floor it seeds (FR-009b). + embeddedSeq := policy.PublisherBaseline if e := rt.embeddedLocked(); e.err == nil && e.hasSeq { embeddedSeq = e.seq } @@ -786,6 +812,15 @@ func readFileCandidate(path string) (*Candidate, *BundleRejection) { } data, berr := readCandidateFile(ctx, path, MaxBundleBytes) if berr != nil { + if present { + // FR-008 stage order: the sidecar's grammar outranks the bundle's byte + // ceiling, so a malformed sidecar is reported as such even when the + // bundle was too large to read. + if _, perr := bundlesig.ParseSidecar(sidecar); perr != nil { + reason, detail := sidecarRejectionFields(perr) + return nil, &BundleRejection{Reason: reason, Detail: detail, At: time.Now()} + } + } return nil, readRejection(berr, "bundle") } return &Candidate{Bundle: data, Sidecar: sidecar, SidecarPresent: present, Source: BundleSourceFile, Path: path}, nil diff --git a/internal/security/scanner/tpa_bundle_runtime_test.go b/internal/security/scanner/tpa_bundle_runtime_test.go index 36fab704d..6905c382a 100644 --- a/internal/security/scanner/tpa_bundle_runtime_test.go +++ b/internal/security/scanner/tpa_bundle_runtime_test.go @@ -532,25 +532,6 @@ func TestRuntime_LKGWriteFailureRefusesActivation(t *testing.T) { assert.Zero(t, f.store.commitCount()) } -func TestRuntime_StateLoadFailureStillScansAndWritesNothing(t *testing.T) { - f := newRTFixture(t, rtOpts{}) - f.store.failLoad = errors.New("bolt: timeout") - f.put(signedCandidate(t, keyPublisher, bundleOpts{Seq: 5})) - - info := f.run() - - assert.Equal(t, BundleSourceFile, info.Source, "the out-of-data-dir anchors still hold, so a verified bundle may serve") - assert.Zero(t, f.store.commitCount(), "nothing is recorded against a state that could not be read") - assert.NotEmpty(t, f.logMessages(zapcore.ErrorLevel)) - assert.Empty(t, f.lkgFiles(), "and the LKG store is not reconciled against an unknown state") - - // Once the store recovers the state loads and the activation is recorded. - f.store.failLoad = nil - info = f.run() - assert.Equal(t, "5", info.Sequence) - assert.Equal(t, 1, f.store.commitCount()) -} - func TestRuntime_NoStoreNoDataDirStillWorks(t *testing.T) { f := newRTFixture(t, rtOpts{NoStore: true, NoDataDir: true}) f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 10})) diff --git a/internal/security/scanner/tpa_bundle_state_logic.go b/internal/security/scanner/tpa_bundle_state_logic.go index df58b65fa..3aff1b21f 100644 --- a/internal/security/scanner/tpa_bundle_state_logic.go +++ b/internal/security/scanner/tpa_bundle_state_logic.go @@ -142,7 +142,11 @@ func downgradeCheck(a *TPAAuthorityState, auth string, policy *bundlePolicy, seq if seq > watermark { return "" } - if seq == watermark && setBy != "" && digest == setBy { + // While a pin to a DIFFERENT target binds, the release that set the watermark + // is not special: a rollback that left it unrecorded (nothing external was + // serving) must not be undone by re-dropping it. + pinBlocksSetter := pinBinds(a) && digest != a.Pin.FullDigest + if seq == watermark && setBy != "" && digest == setBy && !pinBlocksSetter { return "" } diff --git a/internal/security/scanner/tpa_candidate.go b/internal/security/scanner/tpa_candidate.go index 008a1f94c..caf5a15a8 100644 --- a/internal/security/scanner/tpa_candidate.go +++ b/internal/security/scanner/tpa_candidate.go @@ -195,6 +195,19 @@ func (ev *evaluation) unsigned() (*acceptedBundle, *BundleRejection) { if ev.env.State != nil && ev.env.State.SignatureRatchet { return nil, ev.reject(RejectUnsignedRatchet, "a signature-verified bundle has activated on this install; unsigned candidates are refused") } + // An unsigned release has no trustworthy sequence, so it can be neither + // deny-listed nor compared to the watermark. A binding rollback pin is the one + // thing that can keep an operator's rollback from being undone by the next + // refresh (FR-010): while it binds, only the pinned target, or the exact + // digest an explicit rollback names, may activate. + if ev.env.State != nil { + namedByRollback := ev.env.Mode == evalRollback && ev.digest == ev.env.RollbackDigest + if a := ev.env.State.Authorities[ev.policy.ActiveRoot]; pinBinds(a) && ev.digest != a.Pin.FullDigest && !namedByRollback { + return nil, ev.reject(RejectDowngrade, fmt.Sprintf( + "a rollback pin to %s binds; unsigned release %s is refused until the pin is removed or a newer signed release activates", + shortDigest(a.Pin.FullDigest), shortDigest(ev.digest))) + } + } if rej := ev.checkBundleSize(); rej != nil { return nil, rej } @@ -216,15 +229,8 @@ func (ev *evaluation) signed() (*acceptedBundle, *BundleRejection) { sc, err := bundlesig.ParseSidecar(c.Sidecar) if err != nil { - code, detail := bundlesig.CodeOf(err), err.Error() - var be *bundlesig.Error - if errors.As(err, &be) { - detail = be.Detail - } - if code == bundlesig.CodeSidecarTooLarge { - return nil, ev.reject(RejectCeilingExceeded, "sidecar: "+detail) - } - return nil, ev.reject(RejectSidecarInvalid, code) + reason, detail := sidecarRejectionFields(err) + return nil, ev.reject(reason, detail) } if rej := ev.checkBundleSize(); rej != nil { @@ -297,6 +303,21 @@ func (ev *evaluation) signed() (*acceptedBundle, *BundleRejection) { return acc, nil } +// sidecarRejectionFields maps a bundlesig.ParseSidecar failure to the recorded +// rejection reason and detail: an over-long sidecar is a ceiling, everything +// else is a grammar violation whose bundlesig code is the detail. +func sidecarRejectionFields(err error) (reason, detail string) { + code, detail := bundlesig.CodeOf(err), err.Error() + var be *bundlesig.Error + if errors.As(err, &be) { + detail = be.Detail + } + if code == bundlesig.CodeSidecarTooLarge { + return RejectCeilingExceeded, "sidecar: " + detail + } + return RejectSidecarInvalid, code +} + // digestHex is the lowercase-hex SHA-256 of b. func digestHex(b []byte) string { sum := sha256.Sum256(b) From d760005c8c9e1000bde29666e6fd2f42c921d694 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 14:59:00 +0300 Subject: [PATCH 15/35] feat(security): Spec 101 US1 server wiring: bundle runtime, degraded admission gate, telemetry provider Related #938 --- internal/server/server.go | 69 ++++++++++- internal/server/tpa_bundle_admission_test.go | 78 ++++++++++++ .../server/tpa_bundle_operator_stdio_test.go | 78 ++++++++++++ internal/server/tpa_bundle_telemetry.go | 86 +++++++++++++ internal/server/tpa_bundle_telemetry_test.go | 116 ++++++++++++++++++ 5 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 internal/server/tpa_bundle_admission_test.go create mode 100644 internal/server/tpa_bundle_operator_stdio_test.go create mode 100644 internal/server/tpa_bundle_telemetry.go create mode 100644 internal/server/tpa_bundle_telemetry_test.go diff --git a/internal/server/server.go b/internal/server/server.go index 032028053..003aaa7c7 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -104,6 +104,11 @@ func (s *Server) setSecurityScanner(svc securityScannerService) { type Server struct { logger *zap.Logger runtime *runtime.Runtime + // bundleDegradedLogged is the last degraded-coverage reason already logged + // by bundleAdmissionSuspended (Spec 101 FR-007), so the suspension is + // logged once per reason rather than once per settle event. + bundleDegradedMu sync.Mutex + bundleDegradedLogged string // createAfterConfigRead is a Spec 116 test seam (nil in production): it // runs in the create path after the runtime configuration was read and // before the new server is published. @@ -256,6 +261,12 @@ func NewServerWithConfigPath(cfg *config.Config, configPath string, logger *zap. // set and no error was logged. The HTTP path's later // Service.ApplySecurityConfig re-applies the identical path; ConfigureBundle // is idempotent. + // + // Spec 101: the bundle runtime needs the persistent anti-downgrade state + // store and the data directory the last-known-good files live under BEFORE + // the first configure. A nil storage manager (storage failed to open) + // keeps the state in memory only. + configureTPABundleRuntime(rt, cfg) configureTPABundle(cfg, logger) // Initialize update checker with build version @@ -264,6 +275,12 @@ func NewServerWithConfigPath(cfg *config.Config, configPath string, logger *zap. // Initialize telemetry service with build version and edition (Spec 036) rt.SetTelemetry(httpapi.GetBuildVersion(), httpapi.GetEdition()) + // Spec 101 FR-021: active-bundle provenance on every heartbeat, with the + // publisher-authority gate applied by the provider. Wired here, not in the + // HTTP-only block, because the bundle is active on every transport. + if ts := rt.TelemetryService(); ts != nil { + ts.SetTPABundleProvider(tpaBundleTelemetrySnapshot) + } // Initialize observability manager (MCP-32): Prometheus /metrics + OTLP // tracing, config-gated and OFF by default. OAuth refresh metrics (Spec 023, @@ -907,6 +924,14 @@ func (s *Server) maybeAutoApproveScanSettled(ctx context.Context, serverName str if mode != config.TrustModeScan || !sc.Quarantined { return } + // Spec 101 FR-007: a degraded signature bundle (unsigned external drop, an + // embedded corpus below the watermark, an operator authority with no bundle + // or baseline) means the offline tier's coverage is not the coverage a + // green verdict assumes. Suspend auto-approval; the server stays + // quarantined for human review. + if s.bundleAdmissionSuspended(serverName) { + return + } // Admission-window gate (spec 086 FR-011 is a NEW-server admission gate). // A prior integrity baseline means the server was already approved/ // unquarantined at least once, so this quarantine is a deliberate operator @@ -939,6 +964,29 @@ func (s *Server) maybeAutoApproveScanSettled(ctx context.Context, serverName str zap.String("server", serverName)) } +// bundleAdmissionSuspended reports whether scan-mode auto-approval is suspended +// because the active TPA bundle runs with degraded coverage (Spec 101 FR-007). +// The suspension is logged once per distinct reason, not once per settle event; +// a recovered bundle resets the latch so a later degradation logs again. +func (s *Server) bundleAdmissionSuspended(serverName string) bool { + degraded, reason := scanner.BundleCoverageDegraded() + s.bundleDegradedMu.Lock() + if !degraded { + s.bundleDegradedLogged = "" + s.bundleDegradedMu.Unlock() + return false + } + first := s.bundleDegradedLogged != reason + s.bundleDegradedLogged = reason + s.bundleDegradedMu.Unlock() + if first { + s.logger.Info("scan-mode auto-approval suspended: TPA signature bundle coverage is degraded; servers stay quarantined for human review (Spec 101 FR-007)", + zap.String("reason", reason), + zap.String("server", serverName)) + } + return true +} + // maybeStartAdmissionScans implements the "trigger a scan" half of spec 086 // stage-3 new-server admission (FR-011). A scan-mode server is quarantined on // add; without this, nothing ever starts its baseline scan, so it would stay @@ -1080,7 +1128,26 @@ func configureTPABundle(cfg *config.Config, logger *zap.Logger) { if cfg != nil { sec = cfg.Security } - scanner.ConfigureBundle(sec.EffectiveTPABundlePath(), logger) + scanner.ConfigureBundleFromConfig(sec, logger) +} + +// configureTPABundleRuntime injects the persistent TPA bundle state store and the +// data directory into the scanner's bundle runtime (Spec 101 Δ4). It must run +// once, before the first configureTPABundle: it resets the runtime. A nil +// storage manager is passed as an untyped nil interface so the runtime keeps its +// state in memory rather than dereferencing a nil *storage.Manager. +func configureTPABundleRuntime(rt *runtime.Runtime, cfg *config.Config) { + dataDir := "" + if cfg != nil { + dataDir = cfg.DataDir + } + if rt != nil { + if sm := rt.StorageManager(); sm != nil { + scanner.SetBundleRuntime(sm, dataDir) + return + } + } + scanner.SetBundleRuntime(nil, dataDir) } // reapplyScannerSecurityConfig re-applies the opt-in deep-scan gate (and the diff --git a/internal/server/tpa_bundle_admission_test.go b/internal/server/tpa_bundle_admission_test.go new file mode 100644 index 000000000..b5fdc256d --- /dev/null +++ b/internal/server/tpa_bundle_admission_test.go @@ -0,0 +1,78 @@ +package server + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/security/scanner" +) + +// resetBundleRuntimeForTest gives the process-global bundle runtime a hermetic +// in-memory state and the embedded default: earlier tests in this package build +// full servers whose (since closed) storage the runtime would otherwise still +// hold. It resets again on cleanup. +func resetBundleRuntimeForTest(t *testing.T) { + t.Helper() + reset := func() { + scanner.SetBundleRuntime(nil, "") + scanner.ConfigureBundle("", zap.NewNop()) + } + reset() + t.Cleanup(reset) +} + +// configureUnsignedDropBundle installs an UNSIGNED external bundle, which always +// runs with degraded coverage (FR-007), and restores the embedded default after. +func configureUnsignedDropBundle(t *testing.T) { + t.Helper() + resetBundleRuntimeForTest(t) + path := filepath.Join(t.TempDir(), "scanner-bundle.json") + require.NoError(t, os.WriteFile(path, []byte(fileBundle), 0o600)) + scanner.ConfigureBundle(path, zap.NewNop()) + degraded, reason := scanner.BundleCoverageDegraded() + require.True(t, degraded, "an unsigned external bundle must run degraded") + require.Equal(t, scanner.DegradedUnsignedExternalActive, reason) +} + +// TestMaybeAutoApproveScanSettled_DegradedBundleSuspendsAutoApproval is FR-007: +// while the active bundle's coverage is degraded a green baseline scan must NOT +// unquarantine a server; it stays quarantined for human review. +func TestMaybeAutoApproveScanSettled_DegradedBundleSuspendsAutoApproval(t *testing.T) { + t.Run("degraded bundle: no auto-approve, logged once at Info with the reason", func(t *testing.T) { + configureUnsignedDropBundle(t) + fake := newFakeSecurityScanner() + fake.summaries["srv"] = &scanner.ScanSummary{Status: "clean"} + s := newAdmissionTestServer(t, fake, scanModeServer("srv", true)) + core, logs := observer.New(zapcore.InfoLevel) + s.logger = zap.New(core) + + s.maybeAutoApproveScanSettled(context.Background(), "srv") + s.maybeAutoApproveScanSettled(context.Background(), "srv") + + assert.Empty(t, fake.approvedServers()) + entries := logs.FilterField(zap.String("reason", scanner.DegradedUnsignedExternalActive)).All() + require.Len(t, entries, 1, "the degraded suspension is logged once, not per settle event") + assert.Equal(t, zapcore.InfoLevel, entries[0].Level) + }) + + t.Run("not degraded: unchanged behaviour, approves", func(t *testing.T) { + resetBundleRuntimeForTest(t) + degraded, reason := scanner.BundleCoverageDegraded() + require.False(t, degraded, "embedded default must not be degraded (reason %q)", reason) + fake := newFakeSecurityScanner() + fake.summaries["srv"] = &scanner.ScanSummary{Status: "clean"} + s := newAdmissionTestServer(t, fake, scanModeServer("srv", true)) + + s.maybeAutoApproveScanSettled(context.Background(), "srv") + + assert.Equal(t, []string{"srv"}, fake.approvedServers()) + }) +} diff --git a/internal/server/tpa_bundle_operator_stdio_test.go b/internal/server/tpa_bundle_operator_stdio_test.go new file mode 100644 index 000000000..e53bf65b2 --- /dev/null +++ b/internal/server/tpa_bundle_operator_stdio_test.go @@ -0,0 +1,78 @@ +package server + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/security/bundlesig" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/security/scanner" +) + +// operatorSignedBundle renders a one-rule corpus whose manifest names keyID, at +// the given sequence and trust epoch 0. +func operatorSignedBundle(keyID string, seq uint64) []byte { + return []byte(fmt.Sprintf(`{"bundle_version":"0.1.0","key_id":%q,"rules":[{"category":"rug-pull","confidence":0.9,"detector":"op","engine":"regex","id":"TPA-2099-9002","level":"high","pattern":"operator-bundle-canary","target":"tool_description"}],"schema_version":"0.1.0","sequence":"%d","signature_count":1,"trust_epoch":0}`, keyID, seq)) +} + +// TestTPAOperatorSignedBundleActivatesInStdioMode: a stdio-mode server (no +// listen) with an operator-signed bundle configured through +// security.tpa_active_root / tpa_operator_signers / tpa_operator_baseline +// activates it with signature_verified=true (Spec 101 US1). The wiring is +// transport-independent for the same reason as FR-019 in +// tpa_bundle_transport_test.go. +func TestTPAOperatorSignedBundleActivatesInStdioMode(t *testing.T) { + t.Cleanup(func() { scanner.ConfigureBundle("", zap.NewNop()) }) + + priv := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x22}, ed25519.SeedSize)) + pub := priv.Public().(ed25519.PublicKey) + + dir := t.TempDir() + bundlePath := filepath.Join(dir, "scanner-bundle.json") + bundle := operatorSignedBundle(bundlesig.KeyID(pub), 5) + require.NoError(t, os.WriteFile(bundlePath, bundle, 0o600)) + require.NoError(t, os.WriteFile(bundlePath+".sig", bundlesig.SignSidecar(priv, bundle), 0o600)) + + baseline := uint64(1) + cfg := config.DefaultConfig() + cfg.DataDir = dir + cfg.Listen = "" // stdio transport + cfg.Security = &config.SecurityConfig{ + TPABundlePath: bundlePath, + TPAActiveRoot: config.TPARootOperator, + TPAOperatorSigners: []string{base64.StdEncoding.EncodeToString(pub)}, + TPAOperatorBaseline: &baseline, + } + + srv, err := NewServer(cfg, zap.NewNop()) + require.NoError(t, err) + t.Cleanup(func() { _ = srv.Shutdown() }) + + info := scanner.BundleStatus() + assert.Equal(t, scanner.BundleSourceFile, info.Source) + assert.Equal(t, bundlePath, info.Path) + assert.Equal(t, scanner.AuthorityOperator, info.Authority) + assert.True(t, info.SignatureVerified, "an operator-signed bundle must verify") + assert.Equal(t, "5", info.Sequence) + assert.Empty(t, info.LoadError) + assert.False(t, info.CoverageDegraded, "reason %q", info.DegradedReason) + + // The telemetry provider is wired on this transport too and, because the + // authority is the operator's, reports "other" with no sequence. + ts := srv.runtime.TelemetryService() + require.NotNil(t, ts) + payload := ts.BuildPayload() + require.NotNil(t, payload.TPAScanner) + assert.Equal(t, "file", payload.TPAScanner.BundleSource) + assert.Equal(t, "other", payload.TPAScanner.BundleVersion) + assert.Nil(t, payload.TPAScanner.BundleSequence) +} diff --git a/internal/server/tpa_bundle_telemetry.go b/internal/server/tpa_bundle_telemetry.go new file mode 100644 index 000000000..e1c4fc63e --- /dev/null +++ b/internal/server/tpa_bundle_telemetry.go @@ -0,0 +1,86 @@ +package server + +import ( + "strconv" + "strings" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/security/scanner" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/telemetry" +) + +// tpaBundleMaxSequence is 2^53-1, the largest integer every JSON consumer +// represents exactly; the telemetry anonymity backstop refuses anything above. +const tpaBundleMaxSequence = 1<<53 - 1 + +// tpaBundleTelemetrySnapshot is the heartbeat provider (Spec 101 FR-021, +// research D7): it maps the scanner's live bundle status to the wire snapshot. +func tpaBundleTelemetrySnapshot() *telemetry.TPABundleSnapshot { + return tpaBundleSnapshotFromInfo(scanner.BundleStatus()) +} + +// tpaBundleSnapshotFromInfo applies the PROVENANCE gate. The schema line and the +// sequence are reported only when the active bundle's authority is "publisher" +// (the embedded default, or signature-verified against a publisher key). +// Everything else - an unsigned file, or a bundle verified by an operator key - +// reports "other" with no sequence, REGARDLESS of the version string it carries: +// an operator corpus that copies a publisher version string must not be able to +// masquerade as that publisher release in the fleet statistics. +func tpaBundleSnapshotFromInfo(info scanner.BundleInfo) *telemetry.TPABundleSnapshot { + snap := &telemetry.TPABundleSnapshot{ + Source: scanner.BundleSourceFile, + SignatureVerified: info.SignatureVerified, + BundleVersion: "other", + } + if info.Source == scanner.BundleSourceEmbedded { + snap.Source = scanner.BundleSourceEmbedded + } + if info.Authority != scanner.AuthorityPublisher { + return snap + } + // The backstop refuses a schema line for an external bundle that is not + // signature-verified; fail closed here too rather than rely on it. + if snap.Source != scanner.BundleSourceEmbedded && !info.SignatureVerified { + return snap + } + line, ok := tpaSchemaLine(info.BundleVersion) + if !ok { + return snap + } + var seq uint64 + switch { + case info.Sequence != "": + n, err := strconv.ParseUint(info.Sequence, 10, 64) + if err != nil || n > tpaBundleMaxSequence { + return snap + } + seq = n + case snap.Source == scanner.BundleSourceEmbedded: + // The embedded manifest may carry no sequence; report 0. + default: + return snap + } + snap.BundleVersion = line + snap.Sequence = &seq + return snap +} + +// tpaSchemaLine reduces a release string such as "0.1.0" to its "." +// schema line ("0.1"). ok is false when the string does not start with two +// numeric components of at most three digits. +func tpaSchemaLine(version string) (string, bool) { + parts := strings.SplitN(version, ".", 3) + if len(parts) < 2 { + return "", false + } + for _, p := range parts[:2] { + if p == "" || len(p) > 3 { + return "", false + } + for _, r := range p { + if r < '0' || r > '9' { + return "", false + } + } + } + return parts[0] + "." + parts[1], true +} diff --git a/internal/server/tpa_bundle_telemetry_test.go b/internal/server/tpa_bundle_telemetry_test.go new file mode 100644 index 000000000..36c3d6d21 --- /dev/null +++ b/internal/server/tpa_bundle_telemetry_test.go @@ -0,0 +1,116 @@ +package server + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/security/scanner" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/telemetry" +) + +func seqPtr(v uint64) *uint64 { return &v } + +// TestTPABundleSnapshotFromInfo pins the FR-021 provenance gate: the schema +// line and the sequence leave the install only for the embedded default or a +// bundle verified against a PUBLISHER key. +func TestTPABundleSnapshotFromInfo(t *testing.T) { + tests := []struct { + name string + info scanner.BundleInfo + want telemetry.TPABundleSnapshot + }{ + { + name: "embedded default with a sequence", + info: scanner.BundleInfo{Source: scanner.BundleSourceEmbedded, Authority: scanner.AuthorityPublisher, BundleVersion: "0.1.0", Sequence: "42"}, + want: telemetry.TPABundleSnapshot{Source: "embedded", BundleVersion: "0.1", Sequence: seqPtr(42)}, + }, + { + name: "embedded default without a sequence reports 0", + info: scanner.BundleInfo{Source: scanner.BundleSourceEmbedded, Authority: scanner.AuthorityPublisher, BundleVersion: "0.1.0"}, + want: telemetry.TPABundleSnapshot{Source: "embedded", BundleVersion: "0.1", Sequence: seqPtr(0)}, + }, + { + name: "publisher-verified file bundle", + info: scanner.BundleInfo{Source: scanner.BundleSourceFile, Authority: scanner.AuthorityPublisher, SignatureVerified: true, BundleVersion: "0.2.7", Sequence: "9"}, + want: telemetry.TPABundleSnapshot{Source: "file", SignatureVerified: true, BundleVersion: "0.2", Sequence: seqPtr(9)}, + }, + { + name: "operator-signed bundle copying a publisher version string reports other", + info: scanner.BundleInfo{Source: scanner.BundleSourceFile, Authority: scanner.AuthorityOperator, SignatureVerified: true, BundleVersion: "0.1.0", Sequence: "42"}, + want: telemetry.TPABundleSnapshot{Source: "file", SignatureVerified: true, BundleVersion: "other"}, + }, + { + name: "unsigned file bundle copying a publisher version string reports other", + info: scanner.BundleInfo{Source: scanner.BundleSourceFile, BundleVersion: "0.1.0", Sequence: "42"}, + want: telemetry.TPABundleSnapshot{Source: "file", BundleVersion: "other"}, + }, + { + name: "unparseable publisher version reports other", + info: scanner.BundleInfo{Source: scanner.BundleSourceEmbedded, Authority: scanner.AuthorityPublisher, BundleVersion: "nightly-2026", Sequence: "3"}, + want: telemetry.TPABundleSnapshot{Source: "embedded", BundleVersion: "other"}, + }, + { + name: "publisher sequence that is not a decimal reports other", + info: scanner.BundleInfo{Source: scanner.BundleSourceEmbedded, Authority: scanner.AuthorityPublisher, BundleVersion: "0.1.0", Sequence: "abc"}, + want: telemetry.TPABundleSnapshot{Source: "embedded", BundleVersion: "other"}, + }, + { + name: "publisher sequence above 2^53-1 reports other", + info: scanner.BundleInfo{Source: scanner.BundleSourceEmbedded, Authority: scanner.AuthorityPublisher, BundleVersion: "0.1.0", Sequence: "9007199254740992"}, + want: telemetry.TPABundleSnapshot{Source: "embedded", BundleVersion: "other"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tpaBundleSnapshotFromInfo(tc.info) + require.NotNil(t, got) + assert.Equal(t, tc.want, *got) + }) + } +} + +// TestTPABundleSnapshotPassesAnonymityBackstop runs every mapped shape through +// the real heartbeat builder and the telemetry anonymity backstop. +func TestTPABundleSnapshotPassesAnonymityBackstop(t *testing.T) { + prevBlocked := telemetry.BlockedValues + telemetry.BlockedValues = nil + t.Cleanup(func() { telemetry.BlockedValues = prevBlocked }) + + infos := []scanner.BundleInfo{ + {Source: scanner.BundleSourceEmbedded, Authority: scanner.AuthorityPublisher, BundleVersion: "0.1.0", Sequence: "42"}, + {Source: scanner.BundleSourceEmbedded, Authority: scanner.AuthorityPublisher, BundleVersion: "0.1.0"}, + {Source: scanner.BundleSourceFile, Authority: scanner.AuthorityPublisher, SignatureVerified: true, BundleVersion: "0.1.0", Sequence: "7"}, + {Source: scanner.BundleSourceFile, Authority: scanner.AuthorityOperator, SignatureVerified: true, BundleVersion: "0.1.0", Sequence: "7"}, + {Source: scanner.BundleSourceFile, BundleVersion: "0.1.0", Sequence: "7"}, + {Source: scanner.BundleSourceFile, BundleVersion: "weird /home/user/x"}, + } + for _, info := range infos { + snap := tpaBundleSnapshotFromInfo(info) + svc := newBundleTelemetryService(t) + svc.SetTPABundleProvider(func() *telemetry.TPABundleSnapshot { return snap }) + data, err := json.Marshal(svc.BuildPayload()) + require.NoError(t, err) + require.NoError(t, telemetry.ScanForPII(data), "payload for %+v: %s", info, data) + } +} + +func newBundleTelemetryService(t *testing.T) *telemetry.Service { + t.Helper() + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("CI", "") + t.Setenv("MCPPROXY_TELEMETRY", "") + cfg := &config.Config{ + EnableSocket: true, + Features: &config.FeatureFlags{EnableWebUI: true}, + Telemetry: &config.TelemetryConfig{ + AnonymousID: "550e8400-e29b-41d4-a716-446655440000", + AnonymousIDCreatedAt: "2026-04-10T12:00:00Z", + }, + } + return telemetry.New(cfg, "", "v1.2.3", "personal", zap.NewNop()) +} From f0fdbcd58a30b293b159220cabd819f34b5701cc Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 15:37:51 +0300 Subject: [PATCH 16/35] fix(security): Spec 101 US1 review findings Bound the candidate read through a seam that wraps the raw descriptor, and test the limiter and its +1 byte; cover the policy fingerprint's per-signer key ids, UnpinBundle's forced re-run and the ratchet armed by a rollback to a never-activated signed target; content-address the LKG sidecar by bundle and sidecar digest so a reformatted sidecar cannot clobber the recorded pair; prove state and history share one bbolt transaction; scope the tpa_operator_signers redaction exemption to the security config path; resolve security bundle rollback --path against the CLI's cwd; keep ExternalEverActivated across a trust-epoch re-scope so an operator epoch bump with no new bundle degrades (operator_bundle_missing); restore a binding rollback pin's bundle after an active-root round trip. Related #938 --- cmd/mcpproxy/security_bundle_cmd_test.go | 23 +++- cmd/mcpproxy/security_cmd.go | 12 +- docs/features/tpa-bundle-signing.md | 2 +- internal/oauth/configview_tpa_scope_test.go | 81 +++++++++++++ internal/oauth/redactview.go | 41 +++++-- internal/security/scanner/candidate_open.go | 15 ++- .../security/scanner/candidate_open_test.go | 78 ++++++++++++- .../security/scanner/tpa_bundle_crash_test.go | 12 +- .../scanner/tpa_bundle_epoch_missing_test.go | 65 +++++++++++ internal/security/scanner/tpa_bundle_lkg.go | 98 ++++++++++------ .../scanner/tpa_bundle_lkg_sidecar_test.go | 91 +++++++++++++++ .../scanner/tpa_bundle_pin_restore_test.go | 108 ++++++++++++++++++ .../scanner/tpa_bundle_rollback_unpin_test.go | 61 ++++++++++ .../security/scanner/tpa_bundle_runtime.go | 71 ++++++++++++ .../scanner/tpa_bundle_runtime_test.go | 2 +- internal/security/scanner/tpa_bundle_state.go | 15 ++- .../scanner/tpa_bundle_state_logic.go | 13 ++- .../security/scanner/tpa_bundle_state_test.go | 2 +- internal/security/scanner/tpa_policy_test.go | 3 + .../storage/tpa_bundle_state_atomic_test.go | 39 +++++++ 20 files changed, 765 insertions(+), 67 deletions(-) create mode 100644 internal/oauth/configview_tpa_scope_test.go create mode 100644 internal/security/scanner/tpa_bundle_epoch_missing_test.go create mode 100644 internal/security/scanner/tpa_bundle_lkg_sidecar_test.go create mode 100644 internal/security/scanner/tpa_bundle_pin_restore_test.go create mode 100644 internal/security/scanner/tpa_bundle_rollback_unpin_test.go create mode 100644 internal/storage/tpa_bundle_state_atomic_test.go diff --git a/cmd/mcpproxy/security_bundle_cmd_test.go b/cmd/mcpproxy/security_bundle_cmd_test.go index 6ecc8e8db..ab6394e95 100644 --- a/cmd/mcpproxy/security_bundle_cmd_test.go +++ b/cmd/mcpproxy/security_bundle_cmd_test.go @@ -3,6 +3,8 @@ package main import ( "encoding/json" "net/http" + "os" + "path/filepath" "strings" "testing" @@ -125,12 +127,31 @@ func TestSecurityBundleRollback_SendsRequestAndReportsResult(t *testing.T) { req := rec.only(t) assert.Equal(t, http.MethodPost, req.Method) body := req.jsonBody(t) - assert.Equal(t, "/tmp/good.json", body["path"]) + wantPath, absErr := filepath.Abs("/tmp/good.json") + require.NoError(t, absErr) + assert.Equal(t, wantPath, body["path"]) assert.Equal(t, bundleCLIDigest, body["digest"]) assert.NotEqual(t, true, body["embedded"]) assert.Contains(t, out, bundleCLIDigest) } +// The daemon is another process with its own working directory (a tray-started +// core, a service), so a relative --path must reach it already resolved +// against the CLI's cwd. +func TestSecurityBundleRollback_RelativePathIsSentAbsolute(t *testing.T) { + rec := newRESTRecorder(t, map[string]cannedResponse{"POST /api/v1/security/bundle/rollback": okResp(bundleCLIStatusBody())}) + t.Chdir(t.TempDir()) + cwd, err := os.Getwd() + require.NoError(t, err) + + _, _, err = runCLI(t, GetSecurityCommand, "table", "bundle", "rollback", "--path", "./known-good.json", "--digest", bundleCLIDigest) + require.NoError(t, err) + + got, _ := rec.only(t).jsonBody(t)["path"].(string) + assert.True(t, filepath.IsAbs(got), "the daemon must not resolve the path against its own cwd: %q", got) + assert.Equal(t, filepath.Join(cwd, "known-good.json"), got) +} + func TestSecurityBundleRollback_EmbeddedSendsEmbedded(t *testing.T) { rec := newRESTRecorder(t, map[string]cannedResponse{"POST /api/v1/security/bundle/rollback": okResp(bundleCLIStatusBody())}) _, _, err := runCLI(t, GetSecurityCommand, "table", "bundle", "rollback", "--embedded", "--digest", bundleCLIDigest) diff --git a/cmd/mcpproxy/security_cmd.go b/cmd/mcpproxy/security_cmd.go index 1ca89215d..e11cb7458 100644 --- a/cmd/mcpproxy/security_cmd.go +++ b/cmd/mcpproxy/security_cmd.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "os" + "path/filepath" "regexp" "strings" "time" @@ -2475,7 +2476,7 @@ Examples: }, } cmd.Flags().BoolVar(&embedded, "embedded", false, "Roll back to the bundle compiled into this binary") - cmd.Flags().StringVar(&path, "path", "", "Path to the target bundle file (a hint for where to find the bytes)") + cmd.Flags().StringVar(&path, "path", "", "Path to the target bundle file (a hint for where to find the bytes; a relative path is resolved against the current directory)") cmd.Flags().StringVar(&digest, "digest", "", "Full SHA-256 of the target as 64 lowercase hex characters (required)") return cmd } @@ -2640,6 +2641,15 @@ func runSecurityBundleHistory(_ *cobra.Command, _ []string) error { } func runSecurityBundleRollback(embedded bool, path, digest string) error { + // The daemon is another process with its own working directory, so a + // relative path is resolved here, against the shell the operator typed it in. + if path != "" { + abs, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("resolve --path %q: %w", path, err) + } + path = abs + } reqBody, err := json.Marshal(map[string]interface{}{"embedded": embedded, "path": path, "digest": digest}) if err != nil { return fmt.Errorf("failed to marshal request: %w", err) diff --git a/docs/features/tpa-bundle-signing.md b/docs/features/tpa-bundle-signing.md index cabf2b380..4b8d9f2be 100644 --- a/docs/features/tpa-bundle-signing.md +++ b/docs/features/tpa-bundle-signing.md @@ -251,7 +251,7 @@ If an operator private key may have leaked: 3. Author and sign a new bundle with the new `key_id`, `trust_epoch` equal to the new epoch, and a sequence above the new baseline. 4. Drop it and run `mcpproxy security bundle reload`. -After the epoch change, anything signed under the old epoch is `stale_epoch_replay`, and anything signed by the old key is `key_not_trusted`, so an attacker holding the old key can replay nothing. The operator watermark restarts from the baseline, and pins and deny-list entries from the earlier epoch are retired (logged at WARN). +After the epoch change, anything signed under the old epoch is `stale_epoch_replay`, and anything signed by the old key is `key_not_trusted`, so an attacker holding the old key can replay nothing. The operator watermark restarts from the baseline, and pins and deny-list entries from the earlier epoch are retired (logged at WARN). Until you activate a bundle signed in the new epoch, the old operator bundle counts as lost: the embedded default serves and status reports `operator_bundle_missing` (degraded coverage). Publisher epoch bumps are different: they arrive only with a new MCPProxy binary. A bundle with a higher epoch than your binary knows is refused `epoch_ahead` until you update. diff --git a/internal/oauth/configview_tpa_scope_test.go b/internal/oauth/configview_tpa_scope_test.go new file mode 100644 index 000000000..10c1fb42d --- /dev/null +++ b/internal/oauth/configview_tpa_scope_test.go @@ -0,0 +1,81 @@ +package oauth + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// The public-key exemption of security.tpa_operator_signers is scoped to that +// config path. The same key NAME elsewhere is a user-chosen map key (an env +// var, a header, an OAuth extra param, any free-form map) and its value is +// masked like any other secret. +const tpaScopeSecret = "sk-live-AbCdEf0123456789AbCdEf0123456789XYZ" + +func TestRedactedConfig_TPASignerExemptionIsScopedToTheSecurityPath(t *testing.T) { + signer := "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=" + cfg := config.DefaultConfig() + cfg.Security = &config.SecurityConfig{TPAOperatorSigners: []string{signer}} + cfg.Servers = []*config.ServerConfig{{ + Name: "srv", + Command: "node", + Protocol: "stdio", + Enabled: true, + Env: map[string]string{"tpa_operator_signers": tpaScopeSecret, "OTHER": tpaScopeSecret}, + Headers: map[string]string{"tpa_operator_signers": tpaScopeSecret}, + OAuth: &config.OAuthConfig{ExtraParams: map[string]string{"tpa_operator_signers": tpaScopeSecret}}, + }} + + masked := RedactedConfig(cfg) + require.NotNil(t, masked) + + assert.Equal(t, []string{signer}, masked.Security.TPAOperatorSigners, "the real signer list stays readable") + require.Len(t, masked.Servers, 1) + srv := masked.Servers[0] + assert.NotEqual(t, tpaScopeSecret, srv.Env["tpa_operator_signers"], "env var of that name is masked") + assert.Equal(t, srv.Env["OTHER"], srv.Env["tpa_operator_signers"], "masked exactly like any other env var") + assert.NotEqual(t, tpaScopeSecret, srv.Headers["tpa_operator_signers"], "header of that name is masked") + assert.NotEqual(t, tpaScopeSecret, srv.OAuth.ExtraParams["tpa_operator_signers"], "oauth extra param of that name is masked") + assert.Equal(t, tpaScopeSecret, cfg.Servers[0].Env["tpa_operator_signers"], "the source config is untouched") +} + +func TestRedactionValue_TPASignerExemptionNeedsTheSecurityParent(t *testing.T) { + signer := "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=" + list := func() []interface{} { return []interface{}{signer} } + + t.Run("under security: unmasked", func(t *testing.T) { + got := LiveRedaction.Value("", map[string]interface{}{ + "security": map[string]interface{}{"tpa_operator_signers": list()}, + }) + assert.Equal(t, map[string]interface{}{ + "security": map[string]interface{}{"tpa_operator_signers": []interface{}{signer}}, + }, got) + }) + + t.Run("as a free-form string leaf anywhere: masked", func(t *testing.T) { + got := LiveRedaction.Value("", map[string]interface{}{ + "free": map[string]interface{}{"tpa_operator_signers": tpaScopeSecret}, + "tpa_operator_signers": tpaScopeSecret, + }).(map[string]interface{}) + assert.NotEqual(t, tpaScopeSecret, got["tpa_operator_signers"]) + assert.NotEqual(t, tpaScopeSecret, got["free"].(map[string]interface{})["tpa_operator_signers"]) + }) + + t.Run("a list under a non-security parent: masked", func(t *testing.T) { + got := LiveRedaction.Value("", map[string]interface{}{ + "other": map[string]interface{}{"tpa_operator_signers": []interface{}{tpaScopeSecret}}, + }).(map[string]interface{}) + inner := got["other"].(map[string]interface{})["tpa_operator_signers"].([]interface{}) + assert.NotEqual(t, tpaScopeSecret, inner[0]) + }) + + t.Run("a bare string under security is not the signer list: masked", func(t *testing.T) { + got := LiveRedaction.Value("", map[string]interface{}{ + "security": map[string]interface{}{"tpa_operator_signers": tpaScopeSecret}, + }).(map[string]interface{}) + assert.NotEqual(t, tpaScopeSecret, got["security"].(map[string]interface{})["tpa_operator_signers"]) + }) +} diff --git a/internal/oauth/redactview.go b/internal/oauth/redactview.go index 0fe05320c..721a45e9c 100644 --- a/internal/oauth/redactview.go +++ b/internal/oauth/redactview.go @@ -750,6 +750,10 @@ func (r Redaction) Value(key string, v interface{}) interface{} { continue } } + if list, ok := val.([]interface{}); ok && isPublicKeyListPath(key, k) { + out[k] = r.publicKeyList(list) + continue + } out[k] = r.Value(k, val) } return out @@ -766,9 +770,6 @@ func (r Redaction) Value(key string, v interface{}) interface{} { if strings.EqualFold(key, "command") { return r.CapString(r.CommandString(typed)) } - if isPublicKeyMaterialField(key) { - return r.CapString(typed) - } return r.CapString(r.Leaf(key, typed)) default: // Bools, json.Number and nil carry no secrets and stay verbatim. @@ -776,16 +777,34 @@ func (r Redaction) Value(key string, v interface{}) interface{} { } } -// isPublicKeyMaterialField reports whether a leaf's enclosing field holds PUBLIC -// key material that is deliberately readable. Spec 101's -// security.tpa_operator_signers lists operator Ed25519 public keys as base64; -// the value-shaped detector reads a 44-character base64 string as a +// isPublicKeyListPath reports whether the list stored under `child` in a map +// enclosed by `parent` holds PUBLIC key material that is deliberately readable. +// Spec 101's security.tpa_operator_signers lists operator Ed25519 public keys +// as base64; the value-shaped detector reads a 44-character base64 string as a // high-entropy secret and would mask it, so an operator could not read back // what they configured and a UI round trip would write the mask into the trust -// root. A public key is not a credential. Name-matched, not shape-matched, so -// the exemption cannot spread to an arbitrary base64 value. -func isPublicKeyMaterialField(key string) bool { - return strings.EqualFold(key, "tpa_operator_signers") +// root. A public key is not a credential. +// +// The exemption is scoped to that config PATH (a list directly under the +// `security` block), never to the bare key name: the same name elsewhere is a +// user-chosen key of a free-form map (an env var, a header, an OAuth extra +// param) whose value must stay masked. +func isPublicKeyListPath(parent, child string) bool { + return strings.EqualFold(parent, "security") && strings.EqualFold(child, "tpa_operator_signers") +} + +// publicKeyList renders a list of public keys: string elements are only +// length-capped; anything else goes through the ordinary walk. +func (r Redaction) publicKeyList(list []interface{}) []interface{} { + out := make([]interface{}, len(list)) + for i, el := range list { + if s, ok := el.(string); ok { + out[i] = r.CapString(s) + continue + } + out[i] = r.Value("", el) + } + return out } // argvValue masks a command-line argument vector reached through the generic diff --git a/internal/security/scanner/candidate_open.go b/internal/security/scanner/candidate_open.go index 7fc16a755..616695c8b 100644 --- a/internal/security/scanner/candidate_open.go +++ b/internal/security/scanner/candidate_open.go @@ -42,9 +42,10 @@ var ( // before the open syscall (after path resolution), so a test can swap the // path for a FIFO or symlink in the check-to-open window (SC-011a). candidateOpenHook func(path string) - // candidateReaderWrap, when non-nil, wraps the bounded reader of the open - // descriptor, so a test can inject a reader that blocks like a stalled - // network filesystem. + // candidateReaderWrap, when non-nil, wraps the raw reader of the open + // descriptor (before the loader's size limiter), so a test can inject a + // reader that blocks like a stalled network filesystem or that keeps + // delivering after the fstat. candidateReaderWrap func(io.Reader) io.Reader ) @@ -176,11 +177,13 @@ func loadCandidate(path string, limit int64, hook func(string), wrap func(io.Rea if readMax < math.MaxInt64 { readMax++ } - r := io.LimitReader(f, readMax) + // The seam wraps the RAW descriptor, so the limiter below bounds whatever + // the descriptor delivers. + var src io.Reader = f if wrap != nil { - r = wrap(r) + src = wrap(src) } - data, err := io.ReadAll(r) + data, err := io.ReadAll(io.LimitReader(src, readMax)) if err != nil { return nil, &candidateReadError{Reason: RejectReadError, Detail: "read: " + err.Error(), Err: err} } diff --git a/internal/security/scanner/candidate_open_test.go b/internal/security/scanner/candidate_open_test.go index 78ff979b7..4fcf9a075 100644 --- a/internal/security/scanner/candidate_open_test.go +++ b/internal/security/scanner/candidate_open_test.go @@ -61,14 +61,84 @@ func TestReadCandidateFile_CeilingBoundary(t *testing.T) { require.Contains(t, ce.Detail, "65") } +// growingDescriptor stands in for the raw descriptor of a file that keeps +// growing after the fstat: it delivers the real descriptor's bytes, then +// `extra` more 'z' bytes. It counts every byte it +// hands to the consumer so a test can prove the loader's own limiter, not the +// end of the stream, stops the read. +type growingDescriptor struct { + inner io.Reader + extra int64 + innerDone bool + delivered atomic.Int64 +} + +func (g *growingDescriptor) Read(p []byte) (int, error) { + if !g.innerDone { + n, err := g.inner.Read(p) + g.delivered.Add(int64(n)) + if err == io.EOF { + g.innerDone, err = true, nil + } + if n > 0 || err != nil { + return n, err + } + } + if g.extra == 0 { + return 0, io.EOF + } + n := int64(len(p)) + if g.extra > 0 && n > g.extra { + n = g.extra + } + for i := int64(0); i < n; i++ { + p[i] = 'z' + } + if g.extra > 0 { + g.extra -= n + } + g.delivered.Add(n) + return int(n), nil +} + func TestReadCandidateFile_GrowthPastCeilingDuringRead(t *testing.T) { - // The descriptor's size passes the fstat check, but the reader then - // delivers more than max bytes (the file grew). The read is bounded to - // max+1 and refused as ceiling_exceeded. + // The descriptor's size passes the fstat check, but the descriptor then + // delivers a megabyte more (the file grew, or a /proc-style file lied + // about its size). The loader's own limiter must stop the read at max+1 + // bytes. const max = 32 p := writeCandidate(t, "small", []byte("tiny")) + gd := make(chan *growingDescriptor, 1) + candidateReaderWrap = func(r io.Reader) io.Reader { + g := &growingDescriptor{inner: r, extra: 1 << 20} + gd <- g + return g + } + t.Cleanup(func() { candidateReaderWrap = nil }) + + done := make(chan error, 1) + go func() { + _, err := readCandidateFile(context.Background(), p, max) + done <- err + }() + select { + case err := <-done: + requireCandidateErr(t, err, RejectCeilingExceeded) + case <-time.After(3 * time.Second): + t.Fatal("the read of a growing descriptor was not bounded by the loader") + } + g := <-gd + require.LessOrEqual(t, g.delivered.Load(), int64(max+1), "the loader reads at most max+1 bytes") +} + +func TestReadCandidateFile_GrowthByOneByteIsNotTruncated(t *testing.T) { + // A file of exactly max bytes at fstat time that grows by a single byte + // before the read must be refused, not silently truncated to max bytes and + // returned as a successful read (the +1 in the bound). + const max = 32 + p := writeCandidate(t, "exact", bytes.Repeat([]byte{'a'}, max)) candidateReaderWrap = func(r io.Reader) io.Reader { - return io.MultiReader(r, bytes.NewReader(bytes.Repeat([]byte{'z'}, 4*max))) + return &growingDescriptor{inner: r, extra: 1} } t.Cleanup(func() { candidateReaderWrap = nil }) diff --git a/internal/security/scanner/tpa_bundle_crash_test.go b/internal/security/scanner/tpa_bundle_crash_test.go index 483b1a35a..8535fbfcf 100644 --- a/internal/security/scanner/tpa_bundle_crash_test.go +++ b/internal/security/scanner/tpa_bundle_crash_test.go @@ -43,7 +43,7 @@ func TestCrash_AfterLKGWriteBeforeCommit(t *testing.T) { assert.Equal(t, "6", info.Sequence) assert.Equal(t, uint64(6), f.store.persisted(t).Authorities[AuthorityPublisher].Watermark) assert.Len(t, f.store.historyRows(), 2) - assert.ElementsMatch(t, []string{digestOf(b.Bundle) + ".json", digestOf(b.Bundle) + ".json.sig"}, f.lkgFiles()) + assert.ElementsMatch(t, []string{digestOf(b.Bundle) + ".json", digestOf(b.Bundle) + "." + digestOf(b.Sidecar) + ".sig"}, f.lkgFiles()) }) } @@ -145,10 +145,11 @@ func TestLKG_StartupReconcilesOrphans(t *testing.T) { outside := filepath.Join(t.TempDir(), "outside.json") require.NoError(t, os.WriteFile(outside, []byte("precious"), 0o600)) for name, content := range map[string]string{ - orphan + ".json": "orphan bundle", - orphan + ".json.sig": "orphan sidecar", - ".tmp-12345": "interrupted write", - "README.txt": "not ours, left alone", + orphan + ".json": "orphan bundle", + orphan + ".json.sig": "legacy-named orphan sidecar", + orphan + "." + orphan + ".sig": "orphan sidecar", + ".tmp-12345": "interrupted write", + "README.txt": "not ours, left alone", } { require.NoError(t, os.WriteFile(filepath.Join(lkg, name), []byte(content), 0o600)) } @@ -164,6 +165,7 @@ func TestLKG_StartupReconcilesOrphans(t *testing.T) { files := f.lkgFiles() assert.NotContains(t, files, orphan+".json") assert.NotContains(t, files, orphan+".json.sig") + assert.NotContains(t, files, orphan+"."+orphan+".sig") assert.NotContains(t, files, ".tmp-12345") assert.Contains(t, files, "README.txt", "files that are not LKG entries are never touched") if linkName != "" { diff --git a/internal/security/scanner/tpa_bundle_epoch_missing_test.go b/internal/security/scanner/tpa_bundle_epoch_missing_test.go new file mode 100644 index 000000000..157805371 --- /dev/null +++ b/internal/security/scanner/tpa_bundle_epoch_missing_test.go @@ -0,0 +1,65 @@ +package scanner + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// FR-009a(b): an install whose operator root once served an external corpus +// must be degraded when that corpus is "no longer available or no longer +// valid". An operator epoch bump invalidates the old operator bundle +// (stale_epoch_replay); re-scoping the authority record must not forget that +// the operator corpus ever served. +func TestRuntime_OperatorEpochBumpWithoutANewBundleIsOperatorBundleMissing(t *testing.T) { + f := newRTFixture(t, rtOpts{Sec: func(s *config.SecurityConfig) { + s.TPAActiveRoot = config.TPARootOperator + s.TPAOperatorBaseline = u64p(1) + }}) + f.activate(signedCandidate(t, keyOperator, bundleOpts{Seq: 5, Tag: "old-epoch"})) + + // Key-compromise recovery: the operator bumps their epoch; the old bundle + // is now a stale-epoch replay and the embedded default serves. + f.sec.TPAOperatorEpoch = u64p(1) + info := f.run() + + assert.Equal(t, BundleSourceEmbedded, info.Source) + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectStaleEpochReplay, info.LastRejection.Reason) + assert.True(t, info.CoverageDegraded, "the operator's corpus is no longer valid, so the embedded fallback is not full coverage") + assert.Equal(t, DegradedOperatorBundleMissing, info.DegradedReason) + assert.True(t, f.store.persisted(t).Authorities[AuthorityOperator].ExternalEverActivated, + "the re-scope keeps the record that this authority once served a corpus") + + // The same after a restart: the latch is durable, not an in-memory leftover. + f.restart() + info = f.run() + assert.Equal(t, BundleSourceEmbedded, info.Source) + assert.True(t, info.CoverageDegraded) + assert.Equal(t, DegradedOperatorBundleMissing, info.DegradedReason) + + // A newly activated operator bundle in the new epoch serves externally, so + // the condition is moot. + info = f.activate(signedCandidate(t, keyOperator, bundleOpts{Seq: 2, Epoch: 1, Tag: "new-epoch"})) + assert.Equal(t, AuthorityOperator, info.Authority) + assert.False(t, info.CoverageDegraded) +} + +func TestRuntime_OperatorRootWithNothingEverActivatedIsNotDegraded(t *testing.T) { + f := newRTFixture(t, rtOpts{Sec: func(s *config.SecurityConfig) { + s.TPAActiveRoot = config.TPARootOperator + s.TPAOperatorBaseline = u64p(1) + }}) + + info := f.run() + assert.Equal(t, BundleSourceEmbedded, info.Source) + assert.False(t, info.CoverageDegraded, "a fresh install that declared the operator root has lost nothing") + + // Bumping the epoch on such an install has still lost nothing. + f.sec.TPAOperatorEpoch = u64p(1) + info = f.run() + assert.False(t, info.CoverageDegraded) +} diff --git a/internal/security/scanner/tpa_bundle_lkg.go b/internal/security/scanner/tpa_bundle_lkg.go index 0e8b0b3ca..39b5522fd 100644 --- a/internal/security/scanner/tpa_bundle_lkg.go +++ b/internal/security/scanner/tpa_bundle_lkg.go @@ -17,10 +17,14 @@ import ( // restart can recover the (bundle, watermark) pair the persisted state names // even when the operator-configured file has since changed or vanished. // -// Files are named by the SHA-256 of their bytes: .json for the bundle -// and .json.sig for the sidecar. A file whose bytes do not hash to its -// name is treated as missing, so substituting an LKG file can only downgrade -// the install to the embedded default, never put attacker bytes into service. +// Files are named by the SHA-256 of their bytes: .json for the +// bundle and ..sig for the sidecar. The sidecar is +// addressed by its OWN digest as well because its bytes are not a function of +// the bundle's (the same signature can arrive in differently formatted JSON): +// a re-formatted sidecar for the same bundle must never overwrite the file the +// committed state still names. A file whose bytes do not hash to its name is +// treated as missing, so substituting an LKG file can only downgrade the +// install to the embedded default, never put attacker bytes into service. // // Everything here is best-effort against an attacker who controls the data // directory (the spec's threat-model boundary, SC-012); what it guarantees is @@ -50,6 +54,16 @@ var digestHexPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) func lkgDirPath(dataDir string) string { return filepath.Join(dataDir, lkgDirName) } +// lkgSidecarName is the file name of one (bundle, sidecar) pair's sidecar. +func lkgSidecarName(bundleDigest, sidecarDigest string) string { + return bundleDigest + "." + sidecarDigest + ".sig" +} + +// lkgSidecarKey is the reconcile key of one (bundle, sidecar) pair. +func lkgSidecarKey(bundleDigest, sidecarDigest string) string { + return bundleDigest + "." + sidecarDigest +} + // errLKGSubstituted marks an LKG file whose bytes do not hash to the digest the // state recorded for it. var errLKGSubstituted = errors.New("last-known-good file does not match its recorded digest") @@ -73,13 +87,10 @@ func lkgWrite(dataDir, digest string, bundle, sidecar []byte, signed bool) error if err := lkgWriteFile(dir, digest+".json", bundle); err != nil { return err } - sigPath := filepath.Join(dir, digest+".json.sig") if signed { - if err := lkgWriteFile(dir, digest+".json.sig", sidecar); err != nil { + if err := lkgWriteFile(dir, lkgSidecarName(digest, digestHex(sidecar)), sidecar); err != nil { return err } - } else if err := os.Remove(sigPath); err != nil && !errors.Is(err, os.ErrNotExist) { - return err } return lkgSyncDir(dir) } @@ -142,24 +153,33 @@ func lkgSyncDir(dir string) error { // the same descriptor-first, ceiling-bounded reader as a file drop, and checked // against the recorded digests. func lkgRead(ctx context.Context, dataDir string, rec *TPABundleActiveRecord) (Candidate, error) { - if !digestHexPattern.MatchString(rec.FullDigest) { + return lkgReadPair(ctx, dataDir, rec.FullDigest, rec.SidecarDigest, rec.SignatureVerified, rec.Source, rec.Path) +} + +// lkgReadPair reads the bundle (and, when signed, the sidecar) the digests +// name and verifies the bytes hash to them. +func lkgReadPair(ctx context.Context, dataDir, bundleDigest, sidecarDigest string, signed bool, source, path string) (Candidate, error) { + if !digestHexPattern.MatchString(bundleDigest) { return Candidate{}, errLKGSubstituted } dir := lkgDirPath(dataDir) - bundle, err := readCandidateFile(ctx, filepath.Join(dir, rec.FullDigest+".json"), MaxBundleBytes) + bundle, err := readCandidateFile(ctx, filepath.Join(dir, bundleDigest+".json"), MaxBundleBytes) if err != nil { return Candidate{}, err } - if digestHex(bundle) != rec.FullDigest { + if digestHex(bundle) != bundleDigest { return Candidate{}, errLKGSubstituted } - c := Candidate{Bundle: bundle, Source: rec.Source, Path: rec.Path} - if rec.SignatureVerified { - sidecar, err := readCandidateFile(ctx, filepath.Join(dir, rec.FullDigest+".json.sig"), MaxSidecarBytes) + c := Candidate{Bundle: bundle, Source: source, Path: path} + if signed { + if !digestHexPattern.MatchString(sidecarDigest) { + return Candidate{}, errLKGSubstituted + } + sidecar, err := readCandidateFile(ctx, filepath.Join(dir, lkgSidecarName(bundleDigest, sidecarDigest)), MaxSidecarBytes) if err != nil { return Candidate{}, err } - if digestHex(sidecar) != rec.SidecarDigest { + if digestHex(sidecar) != sidecarDigest { return Candidate{}, errLKGSubstituted } c.Sidecar, c.SidecarPresent = sidecar, true @@ -167,19 +187,31 @@ func lkgRead(ctx context.Context, dataDir string, rec *TPABundleActiveRecord) (C return c, nil } -// lkgReferenced is the set of digests the state still needs on disk: the -// serving record and every authority's rollback pin. -func lkgReferenced(st *TPABundleState) map[string]struct{} { - keep := map[string]struct{}{} +// lkgRefs is what the state still needs on disk. +type lkgRefs struct { + bundles map[string]struct{} // bundle digests + sidecars map[string]struct{} // lkgSidecarKey(bundle, sidecar) pairs +} + +// lkgReferenced is the set of files the state still needs: the serving record +// and every authority's rollback pin, each as the exact (bundle, sidecar) pair. +func lkgReferenced(st *TPABundleState) lkgRefs { + keep := lkgRefs{bundles: map[string]struct{}{}, sidecars: map[string]struct{}{}} if st == nil { return keep } + add := func(bundleDigest, sidecarDigest string) { + keep.bundles[bundleDigest] = struct{}{} + if sidecarDigest != "" { + keep.sidecars[lkgSidecarKey(bundleDigest, sidecarDigest)] = struct{}{} + } + } if st.Active != nil { - keep[st.Active.FullDigest] = struct{}{} + add(st.Active.FullDigest, st.Active.SidecarDigest) } for _, a := range st.Authorities { if a != nil && a.Pin != nil { - keep[a.Pin.FullDigest] = struct{}{} + add(a.Pin.FullDigest, a.Pin.SidecarDigest) } } return keep @@ -212,9 +244,20 @@ func lkgReconcile(dataDir string, st *TPABundleState) ([]string, error) { case strings.HasPrefix(name, lkgTempPrefix): remove = true case strings.HasSuffix(name, ".json.sig"): - remove = lkgOrphan(strings.TrimSuffix(name, ".json.sig"), keep) + // The pre-content-addressed sidecar name: never read, always debris. + remove = digestHexPattern.MatchString(strings.TrimSuffix(name, ".json.sig")) + case strings.HasSuffix(name, ".sig"): + stem := strings.TrimSuffix(name, ".sig") + if b, sc, ok := strings.Cut(stem, "."); ok && digestHexPattern.MatchString(b) && digestHexPattern.MatchString(sc) { + _, referenced := keep.sidecars[stem] + remove = !referenced + } case strings.HasSuffix(name, ".json"): - remove = lkgOrphan(strings.TrimSuffix(name, ".json"), keep) + stem := strings.TrimSuffix(name, ".json") + if digestHexPattern.MatchString(stem) { + _, referenced := keep.bundles[stem] + remove = !referenced + } } if !remove { continue @@ -229,12 +272,3 @@ func lkgReconcile(dataDir string, st *TPABundleState) ([]string, error) { } return removed, firstErr } - -// lkgOrphan reports whether stem is a well-formed digest nothing references. -func lkgOrphan(stem string, keep map[string]struct{}) bool { - if !digestHexPattern.MatchString(stem) { - return false - } - _, referenced := keep[stem] - return !referenced -} diff --git a/internal/security/scanner/tpa_bundle_lkg_sidecar_test.go b/internal/security/scanner/tpa_bundle_lkg_sidecar_test.go new file mode 100644 index 000000000..9c00ada18 --- /dev/null +++ b/internal/security/scanner/tpa_bundle_lkg_sidecar_test.go @@ -0,0 +1,91 @@ +package scanner + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// reformatted returns the same signed bundle with a sidecar that differs only +// in JSON whitespace: the signature is identical, the sidecar bytes (and so +// their digest) are not. +func reformatted(c Candidate) Candidate { + return Candidate{Bundle: c.Bundle, Sidecar: append(append([]byte(nil), c.Sidecar...), '\n'), SidecarPresent: true} +} + +// FR-011a: the recorded (bundle, sidecar) pair must stay recoverable until the +// new state is committed. A re-formatted sidecar for the SAME bundle bytes is +// a different activation (the sidecar digest differs); writing its LKG file +// must not clobber the file the committed Active record still names. +func TestLKG_ReformattedSidecarNeverClobbersTheRecordedPair(t *testing.T) { + setup := func(t *testing.T) (*rtFixture, Candidate, Candidate) { + f := newRTFixture(t, rtOpts{}) + a := signedCandidate(t, keyPublisher, bundleOpts{Seq: 5, Tag: "A"}) + f.activate(a) + re := reformatted(a) + require.NotEqual(t, digestOf(a.Sidecar), digestOf(re.Sidecar)) + return f, a, re + } + requireRecordedPairLoads := func(t *testing.T, f *rtFixture, a Candidate) { + t.Helper() + f.restart() + f.remove() + info := f.run() + assert.Equal(t, digestOf(a.Bundle), info.FullDigest, "the recorded active bundle still serves from its LKG files") + assert.Equal(t, BundleSourceFile, info.Source) + assert.True(t, info.SignatureVerified) + assert.False(t, info.CoverageDegraded) + if info.LastRejection != nil { + assert.NotEqual(t, RejectTamper, info.LastRejection.Reason, "the recorded pair is intact, not a tamper") + } + rec := f.store.persisted(t).Active + require.NotNil(t, rec) + assert.Equal(t, digestOf(a.Sidecar), rec.SidecarDigest) + } + + t.Run("commit fails after the reformatted sidecar was written", func(t *testing.T) { + f, a, re := setup(t) + f.store.failCommit = errors.New("disk full") + f.put(re) + info := f.run() + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectStateCommitFailed, info.LastRejection.Reason) + f.store.failCommit = nil + + requireRecordedPairLoads(t, f, a) + }) + + t.Run("process dies between the LKG write and the commit", func(t *testing.T) { + f, a, re := setup(t) + f.put(re) + lkgAfterWriteHook = crash + require.Panics(t, func() { f.run() }) + lkgAfterWriteHook = nil + + requireRecordedPairLoads(t, f, a) + }) +} + +// The sidecar LKG file is content-addressed by BOTH digests, and only the pair +// the state records survives reconciliation. +func TestLKG_SidecarFileIsNamedByBundleAndSidecarDigest(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + a := signedCandidate(t, keyPublisher, bundleOpts{Seq: 5, Tag: "A"}) + f.activate(a) + + want := []string{digestOf(a.Bundle) + ".json", digestOf(a.Bundle) + "." + digestOf(a.Sidecar) + ".sig"} + assert.ElementsMatch(t, want, f.lkgFiles()) + + // A legacy-named sidecar and a stray pair for the same bundle are debris. + dir := filepath.Join(f.dir, "tpa-lkg") + require.NoError(t, os.WriteFile(filepath.Join(dir, digestOf(a.Bundle)+".json.sig"), []byte("legacy"), 0o600)) + other := reformatted(a) + require.NoError(t, os.WriteFile(filepath.Join(dir, digestOf(a.Bundle)+"."+digestOf(other.Sidecar)+".sig"), other.Sidecar, 0o600)) + f.restart() + f.run() + assert.ElementsMatch(t, want, f.lkgFiles(), "reconcile removes every sidecar file the state does not name") +} diff --git a/internal/security/scanner/tpa_bundle_pin_restore_test.go b/internal/security/scanner/tpa_bundle_pin_restore_test.go new file mode 100644 index 000000000..114074140 --- /dev/null +++ b/internal/security/scanner/tpa_bundle_pin_restore_test.go @@ -0,0 +1,108 @@ +package scanner + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// FR-004b / FR-010: switching the active root away suspends the other root's +// pin and deny-list, and switching back restores them UNCHANGED. The pinned +// rollback target must therefore serve again after the round trip, even though +// the configured path still holds the deny-listed release. +func TestPin_SurvivesAnActiveRootRoundTrip(t *testing.T) { + f := newRTFixture(t, rtOpts{Sec: func(s *config.SecurityConfig) { + s.TPAActiveRoot = config.TPARootOperator + s.TPAOperatorBaseline = u64p(1) + }}) + a := signedCandidate(t, keyOperator, bundleOpts{Seq: 5, Tag: "A"}) + b := signedCandidate(t, keyOperator, bundleOpts{Seq: 6, Tag: "B"}) + aPath := f.drop + "/previous.json" + f.putAt(aPath, a) + f.activate(a) + f.activate(b) + + info, err := RollbackBundle(BundleRollbackRequest{Path: aPath, Digest: digestOf(a.Bundle)}, f.log) + require.NoError(t, err) + require.Equal(t, digestOf(a.Bundle), info.FullDigest) + rowsBefore := len(f.store.historyRows()) + + // The operator switches to the publisher root: the pinned operator bundle + // is deactivated and the embedded default serves. + f.sec.TPAActiveRoot = config.TPARootPublisher + info = f.run() + require.Equal(t, BundleSourceEmbedded, info.Source) + require.Nil(t, f.store.persisted(t).Active) + op := f.store.persisted(t).Authorities[AuthorityOperator] + require.NotNil(t, op.Pin, "switching roots suspends the pin, it does not delete it") + require.Len(t, op.DenyList, 1) + + // ... and back. The configured path still holds B, the deny-listed release. + f.sec.TPAActiveRoot = config.TPARootOperator + info = f.run() + + assert.Equal(t, digestOf(a.Bundle), info.FullDigest, "the pinned target is restored unchanged") + assert.Equal(t, BundleSourceFile, info.Source) + assert.Equal(t, AuthorityOperator, info.Authority) + assert.True(t, info.SignatureVerified) + assert.True(t, info.Pinned) + assert.True(t, info.Rollback) + assert.False(t, info.CoverageDegraded, "a restored operator bundle is full coverage, not operator_bundle_missing") + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectDenyListed, info.LastRejection.Reason, "the deny-listed release at the path is still refused") + + st := f.store.persisted(t) + require.NotNil(t, st.Active) + assert.Equal(t, digestOf(a.Bundle), st.Active.FullDigest) + assert.Equal(t, uint64(6), st.Authorities[AuthorityOperator].Watermark, "the watermark is untouched") + assert.Len(t, f.store.historyRows(), rowsBefore, "a restoration is not a new activation") + assert.Contains(t, f.logMessages(zapcore.WarnLevel), "TPA scanner bundle restored from its rollback pin") + + // The restoration is durable: a restart lands on the same bundle. + f.restart() + info = f.run() + assert.Equal(t, digestOf(a.Bundle), info.FullDigest) + assert.True(t, info.Pinned) + + // And the next unchanged reload is a stable no-churn run. + commits := f.store.commitCount() + info = f.run() + assert.Equal(t, digestOf(a.Bundle), info.FullDigest) + assert.Equal(t, commits, f.store.commitCount()) +} + +// A pin whose LKG files are gone cannot be restored: the embedded default +// serves and the install says why (operator_bundle_missing), it does not crash +// or invent a bundle. +func TestPin_RestoreWithoutItsFilesFallsBackToEmbedded(t *testing.T) { + f := newRTFixture(t, rtOpts{Sec: func(s *config.SecurityConfig) { + s.TPAActiveRoot = config.TPARootOperator + s.TPAOperatorBaseline = u64p(1) + }}) + a := signedCandidate(t, keyOperator, bundleOpts{Seq: 5, Tag: "A"}) + b := signedCandidate(t, keyOperator, bundleOpts{Seq: 6, Tag: "B"}) + aPath := f.drop + "/previous.json" + f.putAt(aPath, a) + f.activate(a) + f.activate(b) + _, err := RollbackBundle(BundleRollbackRequest{Path: aPath, Digest: digestOf(a.Bundle)}, f.log) + require.NoError(t, err) + + f.sec.TPAActiveRoot = config.TPARootPublisher + f.run() + for _, name := range f.lkgFiles() { + require.NoError(t, os.Remove(filepath.Join(f.dir, "tpa-lkg", name))) + } + f.sec.TPAActiveRoot = config.TPARootOperator + info := f.run() + + assert.Equal(t, BundleSourceEmbedded, info.Source) + assert.True(t, info.CoverageDegraded) + assert.Equal(t, DegradedOperatorBundleMissing, info.DegradedReason) +} diff --git a/internal/security/scanner/tpa_bundle_rollback_unpin_test.go b/internal/security/scanner/tpa_bundle_rollback_unpin_test.go new file mode 100644 index 000000000..f370391ae --- /dev/null +++ b/internal/security/scanner/tpa_bundle_rollback_unpin_test.go @@ -0,0 +1,61 @@ +package scanner + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// UnpinBundle must re-validate the serving bundle even when nothing about the +// policy or the file bytes changed since the last run: the rolled-back target +// sits AT the configured path, so only the forced re-run (not the idempotence +// shortcut) can notice that, unpinned, it is below the watermark. +func TestRollback_UnpinRevalidatesATargetAtTheConfiguredPath(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + a := signedCandidate(t, keyPublisher, bundleOpts{Seq: 5, Tag: "A"}) + f.activate(a) + f.activate(signedCandidate(t, keyPublisher, bundleOpts{Seq: 6, Tag: "B"})) + + // The operator restores A's bytes to the configured path and rolls back to + // them there; the next reload leaves them serving (pinned). + f.put(a) + _, err := RollbackBundle(BundleRollbackRequest{Path: f.path, Digest: digestOf(a.Bundle)}, f.log) + require.NoError(t, err) + info := ReloadBundle(f.log) + require.Equal(t, digestOf(a.Bundle), info.FullDigest) + require.True(t, info.Pinned) + + info, err = UnpinBundle(f.log) + require.NoError(t, err) + + assert.False(t, info.Pinned) + assert.Equal(t, BundleSourceEmbedded, info.Source, "unpinned, sequence 5 sits below the watermark 6 and stops serving") + assert.Equal(t, DegradedEmbeddedBelowWatermark, info.DegradedReason) + assert.True(t, info.CoverageDegraded) +} + +// FR-010 / FR-009: an explicit rollback to a signed target that never went +// through normal activation is itself a signed activation, so it arms the +// signature ratchet and the "an external corpus once served" latch. +func TestRollback_ToAnUnactivatedSignedTargetArmsTheRatchet(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + target := signedCandidate(t, keyPublisher, bundleOpts{Seq: 7, Tag: "never-activated"}) + targetPath := f.drop + "/target.json" + f.putAt(targetPath, target) + + info, err := RollbackBundle(BundleRollbackRequest{Path: targetPath, Digest: digestOf(target.Bundle)}, f.log) + require.NoError(t, err) + require.Equal(t, digestOf(target.Bundle), info.FullDigest) + + st := f.store.persisted(t) + assert.True(t, st.SignatureRatchet, "a signed rollback target arms the ratchet") + assert.True(t, st.Authorities[AuthorityPublisher].ExternalEverActivated) + + // The ratchet is live: an unsigned drop at the configured path is refused. + f.put(unsignedCandidate(t, bundleOpts{Seq: 8})) + info = f.run() + require.NotNil(t, info.LastRejection) + assert.Equal(t, RejectUnsignedRatchet, info.LastRejection.Reason) + assert.Equal(t, digestOf(target.Bundle), info.FullDigest) +} diff --git a/internal/security/scanner/tpa_bundle_runtime.go b/internal/security/scanner/tpa_bundle_runtime.go index 2350cc0bb..226fb70aa 100644 --- a/internal/security/scanner/tpa_bundle_runtime.go +++ b/internal/security/scanner/tpa_bundle_runtime.go @@ -243,6 +243,9 @@ type selection struct { // files): the embedded default serves for this run, but the committed active // record and its files are kept so the release comes back once reads work. keepActive bool + // restored is the Active record put back from the active root's rollback + // pin (restorePinLocked); serving is then that pin's bundle. + restored *TPABundleActiveRecord } // runLocked is one pipeline run (design §6). The caller holds rt.mu. @@ -268,12 +271,19 @@ func (rt *bundleRuntime) runLocked(policy *bundlePolicy, log *zap.Logger, opts r } sel := rt.chooseLocked(policy, nst, cand, readRej, log) + rt.restorePinLocked(policy, nst, &sel, log, now) st := nst var row *TPABundleActivation activation := false needCommit := len(normLogs) > 0 switch { + case sel.restored != nil: + // A restoration is not a new activation: no history row, no watermark + // movement; only the Active record is put back. + st = cloneState(nst) + st.Active = sel.restored + needCommit = true case sel.fromPath && !sameActive(nst.Active, sel.serving): activation = true sel.serving.acc.Meta.GeneratedAt = sel.serving.generatedAt @@ -494,6 +504,67 @@ func (rt *bundleRuntime) fallbackLocked(policy *bundlePolicy, nst *TPABundleStat sel.serving = embeddedServing(e, policy) } +// restorePinLocked is the last external fallback, tried only when nothing +// external was chosen: when the ACTIVE root's rollback pin binds and names a +// file target, its content-addressed last-known-good files are re-validated in +// LKG mode (the pin allow-rule admits the target below the watermark) and, if +// they pass, serve again and are recorded as the Active record. +// +// This is what makes FR-004b/FR-010 true across a root round trip: switching the +// active root away deactivates the pinned bundle (it no longer belongs to the +// active authority) and clears Active, but the pin is only SUSPENDED, so +// switching back must restore the pinned bundle unchanged rather than leave the +// install on the embedded default while the pin still binds. +func (rt *bundleRuntime) restorePinLocked(policy *bundlePolicy, nst *TPABundleState, sel *selection, log *zap.Logger, now time.Time) { + if policy.Path == "" || rt.dataDir == "" || rt.loadFailed || sel.keepActive || sel.serving == nil || !sel.serving.embedded { + return + } + a := nst.Authorities[policy.ActiveRoot] + if !pinBinds(a) || a.Pin.Embedded || a.Pin.FullDigest == sel.deactivatedDigest { + return + } + pin := a.Pin + source := pin.Source + if source == "" { + source = BundleSourceFile + } + ctx, cancel := context.WithTimeout(context.Background(), bundleReadBudget) + defer cancel() + cand, err := lkgReadPair(ctx, rt.dataDir, pin.FullDigest, pin.SidecarDigest, pin.SidecarDigest != "", source, pin.Path) + if err != nil { + log.Warn("rollback-pinned TPA bundle cannot be restored: its last-known-good files are unavailable", + zap.String("full_digest", pin.FullDigest), zap.Error(err)) + return + } + acc, rej := evaluateCandidate(cand, evalEnv{Policy: policy, State: nst, Mode: evalLKG}) + if rej != nil { + log.Warn("rollback-pinned TPA bundle cannot be restored: it no longer passes validation", + zap.String("full_digest", pin.FullDigest), zap.String("reason", rej.Reason), zap.String("detail", rej.Detail)) + return + } + sv := newExternalServing(cand, acc) + sv.generatedAt = acc.Meta.GeneratedAt + sel.serving = sv + sel.fromPath = false + sel.restored = &TPABundleActiveRecord{ + Authority: acc.Authority, + TrustEpoch: acc.TrustEpoch, + Sequence: acc.Sequence, + FullDigest: acc.FullDigest, + SidecarDigest: acc.SidecarDigest, + SignatureVerified: acc.Verified, + Source: cand.Source, + Path: cand.Path, + GeneratedAt: acc.Meta.GeneratedAt, + ActivatedAt: now, + Rollback: true, + } + log.Warn("TPA scanner bundle restored from its rollback pin", + zap.String("authority", acc.Authority), + zap.String("full_digest", acc.FullDigest), + zap.Uint64("sequence", acc.Sequence)) +} + // restoreFromLKGLocked re-validates the state's Active record from its LKG // files. A missing or substituted file, or a record that no longer validates, // yields a rejection and no serving bundle. The third result is true when the diff --git a/internal/security/scanner/tpa_bundle_runtime_test.go b/internal/security/scanner/tpa_bundle_runtime_test.go index 6905c382a..efc151945 100644 --- a/internal/security/scanner/tpa_bundle_runtime_test.go +++ b/internal/security/scanner/tpa_bundle_runtime_test.go @@ -56,7 +56,7 @@ func TestRuntime_SignedBundleActivates(t *testing.T) { assert.Equal(t, BundleSourceFile, rows[0].Source) assert.Equal(t, 1, f.store.commitCount()) - assert.ElementsMatch(t, []string{digestOf(c.Bundle) + ".json", digestOf(c.Bundle) + ".json.sig"}, f.lkgFiles(), + assert.ElementsMatch(t, []string{digestOf(c.Bundle) + ".json", digestOf(c.Bundle) + "." + digestOf(c.Sidecar) + ".sig"}, f.lkgFiles(), "the activated release is durable in the content-addressed LKG store") } diff --git a/internal/security/scanner/tpa_bundle_state.go b/internal/security/scanner/tpa_bundle_state.go index c2ff7d9a2..f86b68a54 100644 --- a/internal/security/scanner/tpa_bundle_state.go +++ b/internal/security/scanner/tpa_bundle_state.go @@ -88,9 +88,11 @@ type TPAAuthorityState struct { // the watermark is only the seeded baseline). WatermarkSetBy string `json:"watermark_set_by,omitempty"` // ExternalEverActivated records that an external bundle of this authority - // has activated in this epoch — FR-009a(b) (`operator_bundle_missing`) needs - // it to tell "operator bundle lost" from "operator root declared, nothing - // ever activated". + // has ever activated on this install. It SURVIVES a trust-epoch re-scope: + // the bump is what makes that corpus "no longer valid", so FR-009a(b) + // (`operator_bundle_missing`) must still tell "operator bundle lost or + // invalidated" from "operator root declared, nothing ever activated". Only + // a newly activated bundle (which serves externally) makes it moot. ExternalEverActivated bool `json:"external_ever_activated,omitempty"` // Pin is the FR-010 rollback pin. It binds only while this authority is the // active root and Epoch equals the current trusted epoch; switching roots @@ -107,6 +109,13 @@ type TPABundlePin struct { // Embedded is true when the pin is the embedded default. Embedded bool `json:"embedded,omitempty"` PinnedAt time.Time `json:"pinned_at"` + // SidecarDigest, Source and Path identify the exact (bundle, sidecar) pair + // kept in the last-known-good store for this target, so the pin can restore + // it after the active root was switched away and back (FR-004b). Empty for + // the embedded default and for an unsigned target. + SidecarDigest string `json:"sidecar_digest,omitempty"` + Source string `json:"source,omitempty"` + Path string `json:"path,omitempty"` } // TPABundleDenyEntry is one deny-listed release, keyed (authority, epoch, diff --git a/internal/security/scanner/tpa_bundle_state_logic.go b/internal/security/scanner/tpa_bundle_state_logic.go index 3aff1b21f..574e10800 100644 --- a/internal/security/scanner/tpa_bundle_state_logic.go +++ b/internal/security/scanner/tpa_bundle_state_logic.go @@ -83,7 +83,14 @@ func normalizeState(st *TPABundleState, policy *bundlePolicy) (*TPABundleState, logs = append(logs, fmt.Sprintf( "trust epoch for %s changed %d→%d: watermark re-baselined to %d, %d pin/deny entries retired", auth, rec.Epoch, cur, base, retired)) - out.Authorities[auth] = &TPAAuthorityState{Epoch: cur, Watermark: base} + // ExternalEverActivated survives the re-scope: it records that this + // authority once had a serving corpus on this install, and an epoch + // bump is exactly what makes that corpus "no longer valid" + // (FR-009a(b)). Only a bundle newly activated in the new epoch — + // which serves externally — makes the condition moot. Pins, + // deny-list and the watermark setter are retired as FR-005a/FR-010 + // require. + out.Authorities[auth] = &TPAAuthorityState{Epoch: cur, Watermark: base, ExternalEverActivated: rec.ExternalEverActivated} case rec.Watermark < base: // The baseline only ever raises the floor. The floor is now the // baseline, not a bundle, so the recorded setter no longer applies. @@ -321,6 +328,10 @@ func applyRollback(st *TPABundleState, policy *bundlePolicy, target rollbackTarg Embedded: target.Embedded, PinnedAt: now, } + if !target.Embedded { + pin := out.Authorities[root].Pin + pin.SidecarDigest, pin.Source, pin.Path = target.SidecarDigest, target.Source, target.Path + } } if target.Embedded { diff --git a/internal/security/scanner/tpa_bundle_state_test.go b/internal/security/scanner/tpa_bundle_state_test.go index 35f47eaf1..616aa1bc0 100644 --- a/internal/security/scanner/tpa_bundle_state_test.go +++ b/internal/security/scanner/tpa_bundle_state_test.go @@ -75,7 +75,7 @@ func TestNormalizeState_EpochChangeRescopesAndRetiresPinsAndDenyEntries(t *testi assert.Equal(t, uint64(1), pub.Epoch) assert.Equal(t, uint64(100), pub.Watermark, "re-baselined from the binary's own corpus, not zero") assert.Equal(t, "", pub.WatermarkSetBy) - assert.False(t, pub.ExternalEverActivated) + assert.True(t, pub.ExternalEverActivated, "an epoch re-scope keeps the record that the authority once served a corpus (FR-009a(b))") assert.Nil(t, pub.Pin) assert.Empty(t, pub.DenyList) assert.True(t, out.SignatureRatchet, "the ratchet is install-wide and survives an epoch bump") diff --git a/internal/security/scanner/tpa_policy_test.go b/internal/security/scanner/tpa_policy_test.go index e5ce464f9..d7667476a 100644 --- a/internal/security/scanner/tpa_policy_test.go +++ b/internal/security/scanner/tpa_policy_test.go @@ -152,6 +152,9 @@ func TestPolicyFingerprint(t *testing.T) { "operator baseline 1": func(s *config.SecurityConfig) { s.TPAOperatorBaseline = u64p(1) }, "operator epoch": func(s *config.SecurityConfig) { s.TPAOperatorEpoch = u64p(2) }, "extra operator signer": func(s *config.SecurityConfig) { s.TPAOperatorSigners = append(s.TPAOperatorSigners, keyUntrusted.b64) }, + // Same cardinality, different key: only the per-key ids tell these + // apart (an operator rotating or revoking one signer). + "swapped operator signer": func(s *config.SecurityConfig) { s.TPAOperatorSigners = []string{keyUntrusted.b64} }, } seen := map[string]string{"ref": ref} for name, mut := range differs { diff --git a/internal/storage/tpa_bundle_state_atomic_test.go b/internal/storage/tpa_bundle_state_atomic_test.go new file mode 100644 index 000000000..fcb56c525 --- /dev/null +++ b/internal/storage/tpa_bundle_state_atomic_test.go @@ -0,0 +1,39 @@ +package storage + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.etcd.io/bbolt" + berrors "go.etcd.io/bbolt/errors" +) + +// FR-011a / SC-010: the state and the history row of the activation that +// produced it are written in ONE bbolt transaction. The marshal-failure tests +// fail before the transaction opens, so they pass however the writes are +// grouped; this one makes the history append fail INSIDE the transaction, after +// the state Put already ran, and requires the state to be rolled back with it. +func TestTPABundleState_HistoryFailureInsideTheTransactionRollsBackTheState(t *testing.T) { + m := newBoundsManager(t) + + prev := tpaFullState() + require.NoError(t, m.CommitTPABundleState(prev, nil)) + + // A plain value under the "history" key makes CreateBucketIfNotExists fail + // with ErrIncompatibleValue, inside Update, after the state was written. + require.NoError(t, m.GetDB().Update(func(tx *bbolt.Tx) error { + return tx.Bucket([]byte(TPABundleStateBucket)).Put([]byte(tpaBundleHistoryBucket), []byte("not a bucket")) + })) + + next := tpaFullState() + next.Active.Sequence = 99 + next.Authorities["publisher"].Watermark = 99 + err := m.CommitTPABundleState(next, tpaEntry(1)) + require.Error(t, err) + assert.ErrorIs(t, err, berrors.ErrIncompatibleValue) + + got, loadErr := m.LoadTPABundleState() + require.NoError(t, loadErr) + assert.Equal(t, prev, got, "a failed history append must roll the state write back in the same transaction") +} From 721abd3d87ddff115632d36ebd9f5fd9b0d7f62c Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 15:39:59 +0300 Subject: [PATCH 17/35] fix(security): tpa-db reads candidates bounded and replaces the sidecar atomically Cross-model review (Sol 6.1, round 1): verify read the bundle and sidecar with unbounded os.ReadFile (a planted FIFO blocks, a huge file exhausts memory) and sign wrote .sig through a planted symlink. Reads now refuse non-regular files, check the descriptor against the pre-open lstat and cap at the loader's ceilings (sidecar first); writes go to a temp file renamed over the destination entry. Related #938 --- cmd/tpa-db/main.go | 96 +++++++++++++++++++++++++++++++++++++---- cmd/tpa-db/main_test.go | 51 ++++++++++++++++++++++ 2 files changed, 138 insertions(+), 9 deletions(-) diff --git a/cmd/tpa-db/main.go b/cmd/tpa-db/main.go index d3cfdbc3f..c3f0d0951 100644 --- a/cmd/tpa-db/main.go +++ b/cmd/tpa-db/main.go @@ -40,6 +40,10 @@ const ( // authority labels the single operator root inside the one-key trust set // that verify builds; this tool has no notion of a second root. authority = "operator" + // maxBundleBytes mirrors the loader's FR-011 bundle ceiling + // (scanner.MaxBundleBytes); a bundle the proxy would refuse is not worth + // signing or verifying here either. + maxBundleBytes = 8 << 20 ) func main() { @@ -195,7 +199,7 @@ func cmdSign(args []string, stdout, stderr io.Writer) int { return 2 } - bundle, err := os.ReadFile(*bundlePath) + bundle, err := readBounded(*bundlePath, maxBundleBytes) if err != nil { fmt.Fprintf(stderr, "sign: %v\n", err) return 1 @@ -222,12 +226,12 @@ func cmdSign(args []string, stdout, stderr io.Writer) int { dest := *bundlePath if *outPath != "" && *outPath != *bundlePath { dest = *outPath - if err := os.WriteFile(dest, bundle, 0o644); err != nil { + if err := writeReplace(dest, bundle, 0o644); err != nil { fmt.Fprintf(stderr, "sign: %v\n", err) return 1 } } - if err := os.WriteFile(dest+".sig", bundlesig.SignSidecar(priv, bundle), 0o644); err != nil { + if err := writeReplace(dest+".sig", bundlesig.SignSidecar(priv, bundle), 0o644); err != nil { fmt.Fprintf(stderr, "sign: %v\n", err) return 1 } @@ -260,12 +264,9 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "verify: --signer: %v\n", err) return 2 } - bundle, err := os.ReadFile(*bundlePath) - if err != nil { - fmt.Fprintf(stderr, "verify: %v\n", err) - return 1 - } - raw, err := os.ReadFile(*bundlePath + ".sig") + // Sidecar first, through its own small cap, exactly as the loader orders + // it (FR-008): it is the input verification depends on. + raw, err := readBounded(*bundlePath+".sig", bundlesig.MaxSidecarBytes) if err != nil { fmt.Fprintf(stderr, "verify: %v\n", err) return 1 @@ -274,6 +275,11 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int { if err != nil { return fail(err) } + bundle, err := readBounded(*bundlePath, maxBundleBytes) + if err != nil { + fmt.Fprintf(stderr, "verify: %v\n", err) + return 1 + } ts, err := bundlesig.NewTrustSet(bundlesig.Key{ID: bundlesig.KeyID(pub), Public: pub, Authority: authority}) if err != nil { fmt.Fprintf(stderr, "verify: %v\n", err) @@ -292,3 +298,75 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stdout, "OK") return 0 } + +// readBounded reads a regular file of at most limit bytes. The bundle and its +// sidecar may sit in a directory someone else can write, so a symlink, FIFO or +// device is refused outright (a FIFO would block the read forever) and the +// read is capped rather than trusting the size. The open is checked against +// the pre-open lstat with os.SameFile so a swap between the two is refused. +func readBounded(path string, limit int64) ([]byte, error) { + pre, err := os.Lstat(path) + if err != nil { + return nil, err + } + if !pre.Mode().IsRegular() { + return nil, fmt.Errorf("%s is not a regular file", path) + } + f, err := os.Open(path) //nolint:gosec // operator-supplied path; type-checked above and re-checked on the descriptor below + if err != nil { + return nil, err + } + defer f.Close() + post, err := f.Stat() + if err != nil { + return nil, err + } + if !post.Mode().IsRegular() || !os.SameFile(pre, post) { + return nil, fmt.Errorf("%s is not a regular file (changed while opening)", path) + } + data, err := io.ReadAll(io.LimitReader(f, limit+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, fmt.Errorf("%s exceeds the %d-byte limit", path, limit) + } + return data, nil +} + +// writeReplace writes data to a fresh temporary file beside dest and renames +// it over dest. Renaming replaces the directory entry itself, so a symlink +// planted at dest is replaced rather than followed — writing through it would +// let whoever can write the bundle directory make the operator overwrite a +// file of the attacker's choosing (the private key, mcp_config.json). +func writeReplace(dest string, data []byte, perm os.FileMode) error { + tmp, err := os.CreateTemp(filepath.Dir(dest), "."+filepath.Base(dest)+".tmp-*") + if err != nil { + return err + } + name := tmp.Name() + cleanup := func() { _ = os.Remove(name) } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + cleanup() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + cleanup() + return err + } + if err := tmp.Close(); err != nil { + cleanup() + return err + } + if err := os.Chmod(name, perm); err != nil { + cleanup() + return err + } + if err := os.Rename(name, dest); err != nil { + cleanup() + return err + } + return nil +} diff --git a/cmd/tpa-db/main_test.go b/cmd/tpa-db/main_test.go index 452892737..a874e4391 100644 --- a/cmd/tpa-db/main_test.go +++ b/cmd/tpa-db/main_test.go @@ -225,3 +225,54 @@ func TestUsage(t *testing.T) { t.Fatalf("sign without flags: exit %d, want 2", code) } } + +// TestSignDoesNotWriteThroughASidecarSymlink: someone who can write the bundle +// directory plants .sig as a symlink to a file they cannot write +// themselves (the operator key, mcp_config.json). The operator's next sign must +// replace the directory entry, never truncate the symlink's target. +func TestSignDoesNotWriteThroughASidecarSymlink(t *testing.T) { + dir, _, keyID := keygen(t) + bundle := writeBundle(t, keyID) + victim := filepath.Join(t.TempDir(), "victim.json") + if err := os.WriteFile(victim, []byte("precious"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(victim, bundle+".sig"); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + if code, _, errs := runCLI(t, "sign", "--bundle", bundle, "--key", filepath.Join(dir, "operator.key")); code != 0 { + t.Fatalf("sign exit %d: %s", code, errs) + } + if got, _ := os.ReadFile(victim); string(got) != "precious" { + t.Fatalf("sign wrote through the planted symlink: victim now %q", got) + } + st, err := os.Lstat(bundle + ".sig") + if err != nil || !st.Mode().IsRegular() { + t.Fatalf("sidecar should now be a regular file: %v %v", st, err) + } +} + +// TestVerifyBoundsItsReads: verify reads the sidecar first through the 4 KiB +// cap and refuses non-regular files instead of reading them unbounded. +func TestVerifyBoundsItsReads(t *testing.T) { + _, pub, keyID := keygen(t) + bundle := writeBundle(t, keyID) + if err := os.WriteFile(bundle+".sig", bytes.Repeat([]byte(" "), bundlesig.MaxSidecarBytes+1), 0o644); err != nil { + t.Fatal(err) + } + code, _, errs := runCLI(t, "verify", "--bundle", bundle, "--signer", pub) + if code != 1 || !strings.Contains(errs, "exceeds") { + t.Fatalf("oversized sidecar: exit %d, stderr %q", code, errs) + } + + if err := os.Remove(bundle + ".sig"); err != nil { + t.Fatal(err) + } + if err := os.Symlink(bundle, bundle+".sig"); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + code, _, errs = runCLI(t, "verify", "--bundle", bundle, "--signer", pub) + if code != 1 || !strings.Contains(errs, "not a regular file") { + t.Fatalf("symlinked sidecar: exit %d, stderr %q", code, errs) + } +} From 105ccee2672eec17ae9287d7a161b9673be20c02 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 15:40:25 +0300 Subject: [PATCH 18/35] docs(spec-101): CLAUDE.md recent change and tasks.md progress for US1 Related #938 --- CLAUDE.md | 1 + ROADMAP.md | 4 +-- specs/101-tpa-db/tasks.md | 56 +++++++++++++++++++-------------------- 3 files changed, 31 insertions(+), 30 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2df9189ce..f8d4ee05b 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 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.** - 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.** - 117-review-bound-approvals PR-3 (#1565): Go 1.26, backend-only — every upstream `tools/list` carries a `managed.InventoryCapture{Seq, Epoch, Client}` (process-global sequence stamped by the flight leader; `ListToolsCaptured`/`ListToolsFreshCaptured`; `Client.Retire`/`CaptureIsCurrent`; the manager retires replaced/removed clients; `Manager.DiscoverToolsReportCaptured` gives the sweep one capture per server). The runtime admits each inventory under the server's approval lock (`admitInventoryHeld`, per-server `applied`/`definitions` in `toolApprovalServerLock`): an older, retired or reconnected capture writes nothing (records, index, last-good snapshot + its capture, StateView); a lenient empty result is an observation fence; the approval reindex/sweep fallback re-apply the snapshot only while its capture is current, else re-list/skip. Docs: `docs/features/security-quarantine.md` "Tool lists are applied in the order they were captured". **No new dependencies.** - 116-config-commit-consistency PR2: Go 1.26 + a small Web UI change — removal purges the whole name-keyed purge set in the commit's bbolt transaction (`internal/storage/purge.go`, exact OAuth key match), in-memory purge, incarnation-bound approval writers (`internal/runtime/approval_incarnation.go`, REST `expected_incarnation` → 409 `server_replaced`), incarnation-conditional scan/identity/history writes. **No new dependencies.** diff --git a/ROADMAP.md b/ROADMAP.md index 78efa6133..b5ccde207 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1015,7 +1015,7 @@ graph LR | Planning/docs truth automation | In progress | P2 | — | | | | Discovery-quality eval harness (Spec 065 second half) | In progress | P3 | — | [065-evaluation-foundation](./specs/065-evaluation-foundation/) | | | Spec 114 typed pending-call outcome + call correlation for long-running stdio tool calls | In review | P2 | — | | #1536 | -| tpa-db: versioned TPA signature database for the offline scanner | Todo | P1 | 0/30 (0%) | [101-tpa-db](./specs/101-tpa-db/) | | +| tpa-db: versioned TPA signature database for the offline scanner | Todo | P1 | 28/30 (93%) | [101-tpa-db](./specs/101-tpa-db/) | | | Auto routing mode: budget-fitted tool surface per session (spec 104) | Todo | P1 | — | [104-auto-routing-mode](./specs/104-auto-routing-mode/) | | | Windows native tray app `MCP-43` | Todo | P2 | — | | | | Remote access tunnel (feature-flagged MVP, spec 089) | Todo | P2 | — | [089-remote-access-tunnel](./specs/089-remote-access-tunnel/) | | @@ -1156,7 +1156,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/) | `drafted` | 0/30 (0%) | +| [101-tpa-db](./specs/101-tpa-db/) | `in-flight` | 28/30 (93%) | | [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/specs/101-tpa-db/tasks.md b/specs/101-tpa-db/tasks.md index 54ceefe4a..f9cbf8759 100644 --- a/specs/101-tpa-db/tasks.md +++ b/specs/101-tpa-db/tasks.md @@ -47,56 +47,56 @@ plan.md was written 2026-08-26 against Go 1.25.5. Re-validated against `main` @ ## Wave 0 — Setup and shared contract (sequential) -- [ ] T001 Commit this tasks.md. -- [ ] T002 Shared contract (no behaviour): `internal/security/scanner/tpa_bundle_state.go` (state value types, `TPABundleStateStore` interface, authority constants), `tpa_bundle_reasons.go` (rejection + degraded reason constants), `BundleInfo` new fields in `tpa_bundle_source.go`, `SecurityConfig` fields + nil-safe accessors in `internal/config/config.go`. Builds; no test changes. +- [x] T001 Commit this tasks.md. +- [x] T002 Shared contract (no behaviour): `internal/security/scanner/tpa_bundle_state.go` (state value types, `TPABundleStateStore` interface, authority constants), `tpa_bundle_reasons.go` (rejection + degraded reason constants), `BundleInfo` new fields in `tpa_bundle_source.go`, `SecurityConfig` fields + nil-safe accessors in `internal/config/config.go`. Builds; no test changes. ## Wave 1 — Independent foundations (parallel, disjoint packages) ### (a) `internal/security/bundlesig` — sidecar grammar + Ed25519 (FR-003, D3, D10) -- [ ] T010 [P] test: `internal/security/bundlesig/sidecar_test.go` + `testdata/`: `valid.json`/`valid.json.sig`, `accept-trailing-newline`, `accept-all-zero-signature`, one `reject-` per contract row 1–13 asserting the **code**, row-precedence (`{"foo":1}` → `sidecar_missing_field`), exactly 4096 bytes accepted / 4097 `sidecar_too_large`, duplicate key, base64 edge cases (non-canonical trailing bits, embedded newline, 87/89 chars, URL alphabet, missing padding), whitespace/key-order change still verifies (SC-001 non-requirement). -- [ ] T011 [P] impl: `sidecar.go` (total grammar, explicit duplicate-key scan, strict base64 + round-trip), `verify.go` (`TrustSet` with duplicate-id refusal, `KeyID`, `ParsePublicKey`, `Verify`, `CheckKeyIDAgreement`, `SignSidecar`). Pure: no I/O, no globals, stdlib only. -- [ ] T012 [P] test+impl: `cmd/tpa-db` (`keygen`, `sign --bundle --key --out`, `verify --bundle --signer`), stamping nothing into the bundle (operators sign bundles that already carry `sequence`/`key_id`/`trust_epoch`); `sign` refuses a bundle whose manifest `key_id` ≠ the signing key's id. +- [x] T010 [P] test: `internal/security/bundlesig/sidecar_test.go` + `testdata/`: `valid.json`/`valid.json.sig`, `accept-trailing-newline`, `accept-all-zero-signature`, one `reject-` per contract row 1–13 asserting the **code**, row-precedence (`{"foo":1}` → `sidecar_missing_field`), exactly 4096 bytes accepted / 4097 `sidecar_too_large`, duplicate key, base64 edge cases (non-canonical trailing bits, embedded newline, 87/89 chars, URL alphabet, missing padding), whitespace/key-order change still verifies (SC-001 non-requirement). +- [x] T011 [P] impl: `sidecar.go` (total grammar, explicit duplicate-key scan, strict base64 + round-trip), `verify.go` (`TrustSet` with duplicate-id refusal, `KeyID`, `ParsePublicKey`, `Verify`, `CheckKeyIDAgreement`, `SignSidecar`). Pure: no I/O, no globals, stdlib only. +- [x] T012 [P] test+impl: `cmd/tpa-db` (`keygen`, `sign --bundle --key --out`, `verify --bundle --signer`), stamping nothing into the bundle (operators sign bundles that already carry `sequence`/`key_id`/`trust_epoch`); `sign` refuses a bundle whose manifest `key_id` ≠ the signing key's id. ### (c) Descriptor-based candidate open (D5, FR-011, SC-011) -- [ ] T020 [P] test: `internal/security/scanner/candidate_open_test.go` (+ `_unix_test.go`): regular file read; size ceiling exactly-at / one-over from the same descriptor; FIFO refused `not_regular_file` without blocking; symlink-to-FIFO and symlink-to-regular refused (no follow); swap-to-FIFO between resolution and open via the test seam refused; missing file → `ENOENT`-classified; injectable blocking reader → `read_timeout` within the budget and a second call on the same path refused fast while the first reader is parked (one parked reader per path). -- [ ] T021 [P] impl: `candidate_open.go` (budgeted reader, parked-reader registry, `readCandidateFile(ctx, path, max)`), `candidate_open_unix.go` (`//go:build unix`: `O_RDONLY|O_NOFOLLOW|O_NONBLOCK|O_CLOEXEC`, `ELOOP`/`EMLINK` → not regular, `fstat`, clear `O_NONBLOCK`), `candidate_open_windows.go` (copy `internal/codescripts/open_windows.go`: `CreateFile` + `FILE_FLAG_OPEN_REPARSE_POINT`, `GetFileInformationByHandle`, refuse reparse points/directories), `candidate_open_other.go` stub. Never `SetReadDeadline`. +- [x] T020 [P] test: `internal/security/scanner/candidate_open_test.go` (+ `_unix_test.go`): regular file read; size ceiling exactly-at / one-over from the same descriptor; FIFO refused `not_regular_file` without blocking; symlink-to-FIFO and symlink-to-regular refused (no follow); swap-to-FIFO between resolution and open via the test seam refused; missing file → `ENOENT`-classified; injectable blocking reader → `read_timeout` within the budget and a second call on the same path refused fast while the first reader is parked (one parked reader per path). +- [x] T021 [P] impl: `candidate_open.go` (budgeted reader, parked-reader registry, `readCandidateFile(ctx, path, max)`), `candidate_open_unix.go` (`//go:build unix`: `O_RDONLY|O_NOFOLLOW|O_NONBLOCK|O_CLOEXEC`, `ELOOP`/`EMLINK` → not regular, `fstat`, clear `O_NONBLOCK`), `candidate_open_windows.go` (copy `internal/codescripts/open_windows.go`: `CreateFile` + `FILE_FLAG_OPEN_REPARSE_POINT`, `GetFileInformationByHandle`, refuse reparse points/directories), `candidate_open_other.go` stub. Never `SetReadDeadline`. ### (d1) Storage for the anti-downgrade state (D6, FR-011a) -- [ ] T030 [P] test: `internal/storage/tpa_bundle_state_test.go`: absent bucket reads `(nil, nil)` and is not created by a read; commit state + history row in one `Update` (fault-inject a marshal failure → neither written); history append-only and ordered across reopen (SC-010 replay of N entries); server removal (`purge`) leaves the bucket intact. -- [ ] T031 [P] impl: `internal/storage/models.go` `TPABundleStateBucket`, `internal/storage/tpa_bundle_state.go` (`BoltDB` raw methods + `Manager` wrappers implementing `scanner.TPABundleStateStore`; nested `history` bucket keyed by big-endian `NextSequence`), `purge.go` "Not purged" note. +- [x] T030 [P] test: `internal/storage/tpa_bundle_state_test.go`: absent bucket reads `(nil, nil)` and is not created by a read; commit state + history row in one `Update` (fault-inject a marshal failure → neither written); history append-only and ordered across reopen (SC-010 replay of N entries); server removal (`purge`) leaves the bucket intact. +- [x] T031 [P] impl: `internal/storage/models.go` `TPABundleStateBucket`, `internal/storage/tpa_bundle_state.go` (`BoltDB` raw methods + `Manager` wrappers implementing `scanner.TPABundleStateStore`; nested `history` bucket keyed by big-endian `NextSequence`), `purge.go` "Not purged" note. ### (f) Config (FR-004, FR-004b, FR-005a, FR-007, FR-009b; D2) -- [ ] T040 [P] test: `internal/config/tpa_bundle_trust_test.go`: defaults (require off, root publisher, epoch 0, baseline absent); accessors nil-safe; `ValidateDetailed` rejects unknown root, malformed signer, duplicate signer ids; boot-time load never fails on them; JSON round-trip preserves explicit `0` baseline/epoch; `GET /api/v1/config` does not mask the new keys (redaction walk). -- [ ] T041 [P] impl: validation in `ValidateDetailed`; `internal/runtime/config_hotreload.go` security clause → `jsonEqual`; settings/nullable fixtures if required by guard tests. +- [x] T040 [P] test: `internal/config/tpa_bundle_trust_test.go`: defaults (require off, root publisher, epoch 0, baseline absent); accessors nil-safe; `ValidateDetailed` rejects unknown root, malformed signer, duplicate signer ids; boot-time load never fails on them; JSON round-trip preserves explicit `0` baseline/epoch; `GET /api/v1/config` does not mask the new keys (redaction walk). +- [x] T041 [P] impl: validation in `ValidateDetailed`; `internal/runtime/config_hotreload.go` security clause → `jsonEqual`; settings/nullable fixtures if required by guard tests. ### (i) Telemetry backstop (FR-021, D7) -- [ ] T050 [P] test: `internal/telemetry/anonymity_test.go` cases: each typed key accepted; free-string `bundle_version` rejected; `sequence` with `other` rejected; publisher line without `sequence` rejected; `source=file` + `signature_verified=false` + publisher line rejected; non-enum `source` rejected; non-bool `signature_verified` rejected; `sequence` > 2^53−1 rejected. -- [ ] T051 [P] impl: `anonymity.go` typed group + whole-tuple rule; `telemetry.go` `TPABundleSnapshot` provider seam + merge into `tpa_scanner` (emitted even when scan counters are zero); `SchemaVersion` 15 + changelog; pinned schema tests updated. +- [x] T050 [P] test: `internal/telemetry/anonymity_test.go` cases: each typed key accepted; free-string `bundle_version` rejected; `sequence` with `other` rejected; publisher line without `sequence` rejected; `source=file` + `signature_verified=false` + publisher line rejected; non-enum `source` rejected; non-bool `signature_verified` rejected; `sequence` > 2^53−1 rejected. +- [x] T051 [P] impl: `anonymity.go` typed group + whole-tuple rule; `telemetry.go` `TPABundleSnapshot` provider seam + merge into `tpa_scanner` (emitted even when scan counters are zero); `SchemaVersion` 15 + changelog; pinned schema tests updated. ## Wave 2 — Candidate pipeline, state, rollback, coverage (scanner core; one owner, sequential inside) -- [ ] T060 test: `internal/security/scanner/tpa_candidate_test.go` — FR-008 stage order with a stage recorder; verify-before-parse proven by a compile counter (zero compiles on tamper); read-once (the verified buffer is the parsed buffer); SC-001 four tamper classes; `require_signed_bundle` + no sidecar → `signature_required`; present-but-invalid sidecar never falls back to unsigned. -- [ ] T061 test: ceilings SC-010a — bundle 8 MiB activates / 8 MiB+1 `ceiling_exceeded`; 2000 rules / 2001; sidecar 4096 / 4097; read > budget `read_timeout` (injected budget); last-known-good serving throughout. -- [ ] T062 test: manifest grammar — `sequence` decimal-string grammar (reject `0`,`01`,`+1`,` 1`,`1e3`, bare number, 21 digits, 2^64; accept `1`, `18446744073709551615`), `trust_epoch` integer, `key_id` agreement (row 13 → `tamper`). -- [ ] T063 test: `tpa_bundle_state_test.go` anti-downgrade — US1 independent test (a)–(i); SC-002 same-authority same-epoch; equal sequence same digest = idempotent no-op, different digest = `downgrade`; SC-002a (a) max-sequence then epoch bump recovers, (a2) stale-epoch replay refused, (b) authorities isolated, (b2) `authority_not_active`, (c) operator baseline after wipe / unbaselined degraded; `epoch_ahead`; FR-004b declaration switch deactivates the serving bundle; SC-012 substitution (lowered watermark, cleared ratchet, forged pin) — only the out-of-data-dir anchors are asserted. -- [ ] T064 test: crash consistency FR-011a — fault injected after LKG write / before commit, after commit / before publish; every recovery lands on a consistent (bundle, watermark) pair; a preserved rollback never loses its deny-list; startup reconciles orphan `tpa-lkg/*` files; a substituted LKG file (digest mismatch) falls back to embedded. -- [ ] T065 test: `tpa_bundle_rollback_test.go` — FR-010/SC-010: rollback to embedded and to a named file by full digest; 12-hex-prefix collision fixture refused `rollback_target_mismatch`; watermark unchanged; deny-listed release not re-activated by the next reload; operator target measured against the operator baseline; baseline-less operator rollback proceeds degraded; unpin returns to watermark governance; N-activation history replay across restart. -- [ ] T066 test: coverage `inprocess_coverage_test.go` — the four degrade conditions and the must-NOT-degrade cases (fresh install on embedded; embedded ≥ publisher watermark after the external file was removed; operator root declared but never activated; operator watermark 900 vs embedded 100); held reason `scan_coverage` end to end through `ScanToolMetadataVerdict`. -- [ ] T067 impl: `tpa_candidate.go` (source-neutral `Candidate{Bundle, Sidecar []byte; Source; Path}` + pure `decide` + `activate`), `tpa_bundle_source.go` (thin file-drop adapter; `ConfigureBundle` kept as a wrapper; `ConfigureBundleFromConfig`; `SetBundleRuntime`; idempotence; `last_rejection` carried across loads), `tpa_bundle.go` (manifest fields, sequence grammar, rule-count ceiling), `tpa_bundle_state.go` (seeding, epoch scoping, ratchet, pin/deny), `tpa_bundle_lkg.go` (content-addressed LKG: temp → fsync → rename → fsync dir → commit → publish; reconcile), `tpa_bundle_rollback.go`, `tpa_trust_anchors.go`, `inprocess.go` (`coverageOK` degrade), self-check hook. +- [x] T060 test: `internal/security/scanner/tpa_candidate_test.go` — FR-008 stage order with a stage recorder; verify-before-parse proven by a compile counter (zero compiles on tamper); read-once (the verified buffer is the parsed buffer); SC-001 four tamper classes; `require_signed_bundle` + no sidecar → `signature_required`; present-but-invalid sidecar never falls back to unsigned. +- [x] T061 test: ceilings SC-010a — bundle 8 MiB activates / 8 MiB+1 `ceiling_exceeded`; 2000 rules / 2001; sidecar 4096 / 4097; read > budget `read_timeout` (injected budget); last-known-good serving throughout. +- [x] T062 test: manifest grammar — `sequence` decimal-string grammar (reject `0`,`01`,`+1`,` 1`,`1e3`, bare number, 21 digits, 2^64; accept `1`, `18446744073709551615`), `trust_epoch` integer, `key_id` agreement (row 13 → `tamper`). +- [x] T063 test: `tpa_bundle_state_test.go` anti-downgrade — US1 independent test (a)–(i); SC-002 same-authority same-epoch; equal sequence same digest = idempotent no-op, different digest = `downgrade`; SC-002a (a) max-sequence then epoch bump recovers, (a2) stale-epoch replay refused, (b) authorities isolated, (b2) `authority_not_active`, (c) operator baseline after wipe / unbaselined degraded; `epoch_ahead`; FR-004b declaration switch deactivates the serving bundle; SC-012 substitution (lowered watermark, cleared ratchet, forged pin) — only the out-of-data-dir anchors are asserted. +- [x] T064 test: crash consistency FR-011a — fault injected after LKG write / before commit, after commit / before publish; every recovery lands on a consistent (bundle, watermark) pair; a preserved rollback never loses its deny-list; startup reconciles orphan `tpa-lkg/*` files; a substituted LKG file (digest mismatch) falls back to embedded. +- [x] T065 test: `tpa_bundle_rollback_test.go` — FR-010/SC-010: rollback to embedded and to a named file by full digest; 12-hex-prefix collision fixture refused `rollback_target_mismatch`; watermark unchanged; deny-listed release not re-activated by the next reload; operator target measured against the operator baseline; baseline-less operator rollback proceeds degraded; unpin returns to watermark governance; N-activation history replay across restart. +- [x] T066 test: coverage `inprocess_coverage_test.go` — the four degrade conditions and the must-NOT-degrade cases (fresh install on embedded; embedded ≥ publisher watermark after the external file was removed; operator root declared but never activated; operator watermark 900 vs embedded 100); held reason `scan_coverage` end to end through `ScanToolMetadataVerdict`. +- [x] T067 impl: `tpa_candidate.go` (source-neutral `Candidate{Bundle, Sidecar []byte; Source; Path}` + pure `decide` + `activate`), `tpa_bundle_source.go` (thin file-drop adapter; `ConfigureBundle` kept as a wrapper; `ConfigureBundleFromConfig`; `SetBundleRuntime`; idempotence; `last_rejection` carried across loads), `tpa_bundle.go` (manifest fields, sequence grammar, rule-count ceiling), `tpa_bundle_state.go` (seeding, epoch scoping, ratchet, pin/deny), `tpa_bundle_lkg.go` (content-addressed LKG: temp → fsync → rename → fsync dir → commit → publish; reconcile), `tpa_bundle_rollback.go`, `tpa_trust_anchors.go`, `inprocess.go` (`coverageOK` degrade), self-check hook. ## Wave 3 — Surfaces and wiring (parallel, disjoint files) -- [ ] T070 [P] test+impl: server wiring `internal/server/server.go` — `SetBundleRuntime(rt.StorageManager(), cfg.DataDir)` before the first configure; `configureTPABundle` → `ConfigureBundleFromConfig`; `shouldAutoApproveScanSettled` refuses while `scanner.BundleCoverageDegraded()`; telemetry provider wired. Tests under the CI skip regex. -- [ ] T071 [P] test+impl: REST `internal/httpapi` — `GET /security/bundle`, `GET /security/bundle/history`, `POST /security/bundle/rollback`, `POST /security/bundle/unpin`, `POST /security/bundle/reload`; admin-read for GETs, a new agent-denied server op for mutations; swagger annotations; `make swagger`; gating/route-table guard tests. -- [ ] T072 [P] test+impl: CLI `cmd/mcpproxy/security_cmd.go` — `security bundle [-o json|yaml]`, `bundle history`, `bundle rollback (--embedded | --path P) --digest <64 hex>`, `bundle unpin`, `bundle reload`; `signatureBundleLines` shows the new fields and a degraded line with its reason. -- [ ] T073 [P] test+impl: Web UI `frontend/src/utils/signatureBundle.ts`, `frontend/src/views/Security.vue` — full digest on demand, sequence/authority/epoch/verified, degraded banner with reason, last rejection; vitest in `frontend/tests/unit/`. +- [x] T070 [P] test+impl: server wiring `internal/server/server.go` — `SetBundleRuntime(rt.StorageManager(), cfg.DataDir)` before the first configure; `configureTPABundle` → `ConfigureBundleFromConfig`; `shouldAutoApproveScanSettled` refuses while `scanner.BundleCoverageDegraded()`; telemetry provider wired. Tests under the CI skip regex. +- [x] T071 [P] test+impl: REST `internal/httpapi` — `GET /security/bundle`, `GET /security/bundle/history`, `POST /security/bundle/rollback`, `POST /security/bundle/unpin`, `POST /security/bundle/reload`; admin-read for GETs, a new agent-denied server op for mutations; swagger annotations; `make swagger`; gating/route-table guard tests. +- [x] T072 [P] test+impl: CLI `cmd/mcpproxy/security_cmd.go` — `security bundle [-o json|yaml]`, `bundle history`, `bundle rollback (--embedded | --path P) --digest <64 hex>`, `bundle unpin`, `bundle reload`; `signatureBundleLines` shows the new fields and a degraded line with its reason. +- [x] T073 [P] test+impl: Web UI `frontend/src/utils/signatureBundle.ts`, `frontend/src/views/Security.vue` — full digest on demand, sequence/authority/epoch/verified, degraded banner with reason, last rejection; vitest in `frontend/tests/unit/`. ## Wave 4 — Verify, review, ship -- [ ] T080 `./scripts/test-api-e2e.sh`; `go test -race ./internal/security/... ./internal/storage/... ./internal/config/... ./internal/telemetry/...`; the CLAUDE.md skip-regex race run over `internal/server`/`httpapi`; golangci-lint v2 bare and `--build-tags server`. -- [ ] T081 Review workflow (dimensions → 3 refuters per finding → fix confirmed). +- [x] T080 `./scripts/test-api-e2e.sh`; `go test -race ./internal/security/... ./internal/storage/... ./internal/config/... ./internal/telemetry/...`; the CLAUDE.md skip-regex race run over `internal/server`/`httpapi`; golangci-lint v2 bare and `--build-tags server`. +- [x] T081 Review workflow (dimensions → 3 refuters per finding → fix confirmed). - [ ] T082 Cross-model review (opencode Sol 6.1; ≤10 rounds). -- [ ] T083 Docs: `docs/features/tpa-bundle-signing.md` (operator: verify, drop, reload, roll back, read status, air-gapped walkthrough, Windows/NFS residual), `docs/features/security-quarantine.md` + tool-scanner docs (degraded states), `docs/configuration.md` (new keys), quickstart.md corrections (Δ5, Δ8, Δ13, sequence ≥ baseline), `CLAUDE.md` Recent Changes, `roadmap.yaml` + `scripts/gen-roadmap.py`. +- [x] T083 Docs: `docs/features/tpa-bundle-signing.md` (operator: verify, drop, reload, roll back, read status, air-gapped walkthrough, Windows/NFS residual), `docs/features/security-quarantine.md` + tool-scanner docs (degraded states), `docs/configuration.md` (new keys), quickstart.md corrections (Δ5, Δ8, Δ13, sequence ≥ baseline), `CLAUDE.md` Recent Changes, `roadmap.yaml` + `scripts/gen-roadmap.py`. - [ ] T084 Open the PR; tick this file. --- From 41e11aec074657a2764a7c4c22c9d85b427b6879 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 15:47:30 +0300 Subject: [PATCH 19/35] fix(security): Spec 101 US1 cross-review round 1 (LKG reads, parent fsync, signer redaction door) Sol 6.1 round 1 findings: - lkgWrite compared an existing LKG file with os.ReadFile; the data dir is attacker-writable, so compare only through the descriptor-first bounded reader and replace anything else. - creating tpa-lkg/ now fsyncs its parent so the directory entry is durable before the state commit (FR-011a). - the tpa_operator_signers public-key exemption moved out of the generic redaction walker (which also redacts audit payloads, where the field names are attacker-chosen) into RedactedConfig, restoring only canonical 32-byte public keys. Rejected as by-design (recorded as tasks.md A11): activation at exactly the baseline sequence. Related #938 --- internal/oauth/configview.go | 33 +++++++++ internal/oauth/configview_tpa_scope_test.go | 69 ++++++++++--------- internal/oauth/redactview.go | 34 --------- internal/security/scanner/tpa_bundle_lkg.go | 29 ++++++-- .../scanner/tpa_bundle_lkg_write_test.go | 68 ++++++++++++++++++ specs/101-tpa-db/tasks.md | 1 + 6 files changed, 161 insertions(+), 73 deletions(-) create mode 100644 internal/security/scanner/tpa_bundle_lkg_write_test.go diff --git a/internal/oauth/configview.go b/internal/oauth/configview.go index ba4f73e48..1c4ffb270 100644 --- a/internal/oauth/configview.go +++ b/internal/oauth/configview.go @@ -1,6 +1,7 @@ package oauth import ( + "encoding/base64" "encoding/json" "reflect" @@ -42,9 +43,41 @@ func RedactedConfig(cfg *config.Config) *config.Config { if !LiveRedaction.RedactNested("", &masked) { return nil } + restorePublicSigners(cfg, &masked) return &masked } +// restorePublicSigners puts security.tpa_operator_signers back verbatim after +// the generic walk masked them. They are Ed25519 PUBLIC keys (spec 101): the +// value-shaped detector reads a 44-character base64 string as a high-entropy +// secret, and a masked trust root could neither be read back by the operator +// nor survive a GET -> PATCH round trip. The exemption lives HERE, in the typed +// config door, and never in the generic walker, which also redacts audit +// payloads and request bodies where the same field names are attacker-chosen. +// Only an entry that IS a canonical 32-byte public key is restored; anything +// else in the list stays masked like any secret. +func restorePublicSigners(src, masked *config.Config) { + if src.Security == nil || masked.Security == nil || len(src.Security.TPAOperatorSigners) == 0 || + len(masked.Security.TPAOperatorSigners) != len(src.Security.TPAOperatorSigners) { + return + } + sec := *masked.Security + sec.TPAOperatorSigners = append([]string(nil), masked.Security.TPAOperatorSigners...) + for i, s := range src.Security.TPAOperatorSigners { + if isPublicKeyShape(s) { + sec.TPAOperatorSigners[i] = s + } + } + masked.Security = &sec +} + +// isPublicKeyShape reports whether s is the canonical standard-base64 encoding +// of exactly 32 bytes (an Ed25519 public key). +func isPublicKeyShape(s string) bool { + b, err := base64.StdEncoding.Strict().DecodeString(s) + return err == nil && len(b) == 32 && base64.StdEncoding.EncodeToString(b) == s +} + // UnmaskLiveConfigTree is the write twin of RedactedConfig: it reverts every // mask a live read door rendered that can be BOUND TO A KEY, and refuses // whatever is left. diff --git a/internal/oauth/configview_tpa_scope_test.go b/internal/oauth/configview_tpa_scope_test.go index 10c1fb42d..e36a0105a 100644 --- a/internal/oauth/configview_tpa_scope_test.go +++ b/internal/oauth/configview_tpa_scope_test.go @@ -42,40 +42,41 @@ func TestRedactedConfig_TPASignerExemptionIsScopedToTheSecurityPath(t *testing.T assert.Equal(t, tpaScopeSecret, cfg.Servers[0].Env["tpa_operator_signers"], "the source config is untouched") } -func TestRedactionValue_TPASignerExemptionNeedsTheSecurityParent(t *testing.T) { - signer := "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=" - list := func() []interface{} { return []interface{}{signer} } - - t.Run("under security: unmasked", func(t *testing.T) { - got := LiveRedaction.Value("", map[string]interface{}{ - "security": map[string]interface{}{"tpa_operator_signers": list()}, +// The generic walker never exempts anything by field name: it also redacts +// audit payloads and request bodies, where a "security.tpa_operator_signers" +// shape is attacker-chosen and may carry a real credential (Sol 6.1 review, +// round 1). The exemption lives only in the typed config read door. +func TestRedactionValue_NeverExemptsTheSignerPathInGenericPayloads(t *testing.T) { + const credential = "mcp_agt_1234567890abcdef1234567890abcdef" + payload := func() map[string]interface{} { + return map[string]interface{}{ + "request": map[string]interface{}{ + "security": map[string]interface{}{"tpa_operator_signers": []interface{}{credential}}, + }, + "security": map[string]interface{}{"tpa_operator_signers": []interface{}{credential}}, + } + } + for name, r := range map[string]Redaction{"live": LiveRedaction, "audit": AuditRedaction} { + t.Run(name, func(t *testing.T) { + got := r.Value("", payload()).(map[string]interface{}) + top := got["security"].(map[string]interface{})["tpa_operator_signers"].([]interface{}) + nested := got["request"].(map[string]interface{})["security"].(map[string]interface{})["tpa_operator_signers"].([]interface{}) + assert.NotEqual(t, credential, top[0], "top-level shape is masked in a generic payload") + assert.NotEqual(t, credential, nested[0], "nested shape is masked") }) - assert.Equal(t, map[string]interface{}{ - "security": map[string]interface{}{"tpa_operator_signers": []interface{}{signer}}, - }, got) - }) - - t.Run("as a free-form string leaf anywhere: masked", func(t *testing.T) { - got := LiveRedaction.Value("", map[string]interface{}{ - "free": map[string]interface{}{"tpa_operator_signers": tpaScopeSecret}, - "tpa_operator_signers": tpaScopeSecret, - }).(map[string]interface{}) - assert.NotEqual(t, tpaScopeSecret, got["tpa_operator_signers"]) - assert.NotEqual(t, tpaScopeSecret, got["free"].(map[string]interface{})["tpa_operator_signers"]) - }) - - t.Run("a list under a non-security parent: masked", func(t *testing.T) { - got := LiveRedaction.Value("", map[string]interface{}{ - "other": map[string]interface{}{"tpa_operator_signers": []interface{}{tpaScopeSecret}}, - }).(map[string]interface{}) - inner := got["other"].(map[string]interface{})["tpa_operator_signers"].([]interface{}) - assert.NotEqual(t, tpaScopeSecret, inner[0]) - }) + } +} - t.Run("a bare string under security is not the signer list: masked", func(t *testing.T) { - got := LiveRedaction.Value("", map[string]interface{}{ - "security": map[string]interface{}{"tpa_operator_signers": tpaScopeSecret}, - }).(map[string]interface{}) - assert.NotEqual(t, tpaScopeSecret, got["security"].(map[string]interface{})["tpa_operator_signers"]) - }) +// Only values that ARE public keys come back verbatim from the config read +// door; anything else in the list is masked like any secret. +func TestRedactedConfig_OnlyPublicKeyShapedSignersAreReadable(t *testing.T) { + signer := "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=" + cfg := config.DefaultConfig() + cfg.Security = &config.SecurityConfig{TPAOperatorSigners: []string{signer, tpaScopeSecret}} + masked := RedactedConfig(cfg) + require.NotNil(t, masked) + require.Len(t, masked.Security.TPAOperatorSigners, 2) + assert.Equal(t, signer, masked.Security.TPAOperatorSigners[0]) + assert.NotEqual(t, tpaScopeSecret, masked.Security.TPAOperatorSigners[1]) + assert.Equal(t, []string{signer, tpaScopeSecret}, cfg.Security.TPAOperatorSigners, "the source config is untouched") } diff --git a/internal/oauth/redactview.go b/internal/oauth/redactview.go index 721a45e9c..495c28916 100644 --- a/internal/oauth/redactview.go +++ b/internal/oauth/redactview.go @@ -750,10 +750,6 @@ func (r Redaction) Value(key string, v interface{}) interface{} { continue } } - if list, ok := val.([]interface{}); ok && isPublicKeyListPath(key, k) { - out[k] = r.publicKeyList(list) - continue - } out[k] = r.Value(k, val) } return out @@ -777,36 +773,6 @@ func (r Redaction) Value(key string, v interface{}) interface{} { } } -// isPublicKeyListPath reports whether the list stored under `child` in a map -// enclosed by `parent` holds PUBLIC key material that is deliberately readable. -// Spec 101's security.tpa_operator_signers lists operator Ed25519 public keys -// as base64; the value-shaped detector reads a 44-character base64 string as a -// high-entropy secret and would mask it, so an operator could not read back -// what they configured and a UI round trip would write the mask into the trust -// root. A public key is not a credential. -// -// The exemption is scoped to that config PATH (a list directly under the -// `security` block), never to the bare key name: the same name elsewhere is a -// user-chosen key of a free-form map (an env var, a header, an OAuth extra -// param) whose value must stay masked. -func isPublicKeyListPath(parent, child string) bool { - return strings.EqualFold(parent, "security") && strings.EqualFold(child, "tpa_operator_signers") -} - -// publicKeyList renders a list of public keys: string elements are only -// length-capped; anything else goes through the ordinary walk. -func (r Redaction) publicKeyList(list []interface{}) []interface{} { - out := make([]interface{}, len(list)) - for i, el := range list { - if s, ok := el.(string); ok { - out[i] = r.CapString(s) - continue - } - out[i] = r.Value("", el) - } - return out -} - // argvValue masks a command-line argument vector reached through the generic // walk, using the same Argv rules the typed doors use. // diff --git a/internal/security/scanner/tpa_bundle_lkg.go b/internal/security/scanner/tpa_bundle_lkg.go index 39b5522fd..f990f0e75 100644 --- a/internal/security/scanner/tpa_bundle_lkg.go +++ b/internal/security/scanner/tpa_bundle_lkg.go @@ -81,9 +81,19 @@ func lkgWrite(dataDir, digest string, bundle, sidecar []byte, signed bool) error return fmt.Errorf("refusing to name an LKG file after %q", digest) } dir := lkgDirPath(dataDir) + _, statErr := os.Lstat(dir) + created := errors.Is(statErr, os.ErrNotExist) if err := os.MkdirAll(dir, 0o700); err != nil { return err } + if created { + // A new directory's own entry lives in its parent: fsync the parent + // too, or a power loss after the state commit could leave an Active + // record whose tpa-lkg/ never reached the disk (FR-011a). + if err := lkgSyncDirFn(dataDir); err != nil { + return err + } + } if err := lkgWriteFile(dir, digest+".json", bundle); err != nil { return err } @@ -92,16 +102,21 @@ func lkgWrite(dataDir, digest string, bundle, sidecar []byte, signed bool) error return err } } - return lkgSyncDir(dir) + return lkgSyncDirFn(dir) } // lkgWriteFile writes data to dir/name via a synced temp file and a rename. func lkgWriteFile(dir, name string, data []byte) error { final := filepath.Join(dir, name) - if st, err := os.Lstat(final); err == nil && st.Mode().IsRegular() { - if existing, rerr := os.ReadFile(final); rerr == nil && bytes.Equal(existing, data) { //nolint:gosec // path is dataDir/tpa-lkg/ - return nil - } + // The data directory is attacker-writable in the threat model, so an + // existing file is compared only through the descriptor-first, bounded, + // budgeted reader (a planted FIFO or oversized file is never read raw); + // anything that does not read back as exactly these bytes is replaced. + ctx, cancel := context.WithTimeout(context.Background(), bundleReadBudget) + existing, rerr := readCandidateFile(ctx, final, int64(len(data))) + cancel() + if rerr == nil && bytes.Equal(existing, data) { + return nil } tmp, err := os.CreateTemp(dir, lkgTempPrefix+"*") if err != nil { @@ -130,6 +145,10 @@ func lkgWriteFile(dir, name string, data []byte) error { return nil } +// lkgSyncDirFn is lkgSyncDir behind a test seam (tests record which +// directories were made durable). +var lkgSyncDirFn = lkgSyncDir + // lkgSyncDir fsyncs the directory so the renames are durable. Directory fsync // is not supported on Windows (opening a directory handle for sync fails // there); NTFS journals the rename itself, so it is skipped. diff --git a/internal/security/scanner/tpa_bundle_lkg_write_test.go b/internal/security/scanner/tpa_bundle_lkg_write_test.go new file mode 100644 index 000000000..62dc4696c --- /dev/null +++ b/internal/security/scanner/tpa_bundle_lkg_write_test.go @@ -0,0 +1,68 @@ +package scanner + +import ( + "os" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +// An LKG file that already exists sits in the data directory, which the +// threat model lets the attacker write. lkgWrite's idempotence check must read +// it through the descriptor-first, bounded reader — never os.ReadFile, which +// would read an arbitrarily large planted file into memory, or block on a FIFO +// swapped in after the type check (Sol 6.1 review, round 1). +func TestLKGWrite_ExistingFileIsReadThroughTheGuardedReader(t *testing.T) { + dataDir := t.TempDir() + data := []byte(`{"bundle_version":"0.1.0"}`) + digest := digestHex(data) + require.NoError(t, lkgWrite(dataDir, digest, data, nil, false)) + + final := filepath.Join(lkgDirPath(dataDir), digest+".json") + var mu sync.Mutex + opened := 0 + prev := candidateOpenHook + candidateOpenHook = func(p string) { + if p == final { + mu.Lock() + opened++ + mu.Unlock() + } + } + t.Cleanup(func() { candidateOpenHook = prev }) + + require.NoError(t, lkgWrite(dataDir, digest, data, nil, false)) + mu.Lock() + defer mu.Unlock() + require.Equal(t, 1, opened, "the existing LKG file must be compared through readCandidateFile") + + // A planted oversized file under the final name is replaced, not trusted. + require.NoError(t, os.WriteFile(final, append(append([]byte{}, data...), make([]byte, 1<<20)...), 0o600)) + require.NoError(t, lkgWrite(dataDir, digest, data, nil, false)) + got, err := os.ReadFile(final) + require.NoError(t, err) + require.Equal(t, data, got) +} + +// The first activation creates tpa-lkg/; its entry in the data directory must +// be made durable too, or a power loss after the state commit can leave an +// Active record whose LKG directory never reached the disk (FR-011a). +func TestLKGWrite_FirstWriteSyncsTheParentDirectory(t *testing.T) { + dataDir := t.TempDir() + var synced []string + prev := lkgSyncDirFn + lkgSyncDirFn = func(dir string) error { synced = append(synced, dir); return prev(dir) } + t.Cleanup(func() { lkgSyncDirFn = prev }) + + data := []byte(`{"bundle_version":"0.1.0"}`) + require.NoError(t, lkgWrite(dataDir, digestHex(data), data, nil, false)) + require.Contains(t, synced, lkgDirPath(dataDir)) + require.Contains(t, synced, dataDir, "creating tpa-lkg must fsync its parent") + + synced = nil + other := []byte(`{"bundle_version":"0.1.1"}`) + require.NoError(t, lkgWrite(dataDir, digestHex(other), other, nil, false)) + require.NotContains(t, synced, dataDir, "an existing tpa-lkg needs no parent fsync") +} diff --git a/specs/101-tpa-db/tasks.md b/specs/101-tpa-db/tasks.md index f9cbf8759..65f2019ae 100644 --- a/specs/101-tpa-db/tasks.md +++ b/specs/101-tpa-db/tasks.md @@ -41,6 +41,7 @@ plan.md was written 2026-08-26 against Go 1.25.5. Re-validated against `main` @ - **A7 "sidecar absent"** means `ENOENT` only; a present-but-invalid sidecar is a rejection, never a fallback to the unsigned path. - **A8 the never-written FIFO** of SC-011(c) is refused immediately as `not_regular_file` (the `O_NONBLOCK` open never starts a read); the 5 s budget and the one-parked-reader-per-path cap are proven with an injectable blocking reader. - **A9 config**: `require_signed_bundle` (plain bool, default **false**, maintainer decision D2), `tpa_active_root` (`publisher`|`operator`, default publisher), `tpa_operator_signers` (base64 raw Ed25519 public keys), `tpa_operator_baseline` (`*uint64`, absent = unbaselined → degraded), `tpa_operator_epoch` (`*uint64`, absent = 0). Malformed/duplicate signers fail `ValidateDetailed` (write-time) and are dropped with a log at load (never boot-fatal). No env aliases: the trust root is config-resident by design. +- **A11 baselines are exclusive for activation**: a baseline SEEDS the authority's watermark (FR-009b), so normal activation needs a sequence strictly above it (equal sequence with a different digest is the FR-009 equal-sequence refusal); only an explicit rollback target may sit AT its authority's baseline (FR-010 "at or above"). Quickstart signs sequence 2 against operator baseline 1 for this reason. - **A10 no config writes**: all persisted state is BBolt; Spec 116 `Runtime.Commit` is not involved. --- From bc8ca47d81cc113b9e7543bb86cbf97eb7d2e00c Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 15:48:57 +0300 Subject: [PATCH 20/35] docs(roadmap): tpa-db-format in review as #1701 Related #938 --- ROADMAP.md | 64 +++++++++++++++++++++++++++------------------------- roadmap.yaml | 7 +++--- 2 files changed, 37 insertions(+), 34 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b5ccde207..70993dfb9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -83,9 +83,9 @@ graph LR classDef in_review fill:#9a6700,stroke:#5c3d00,color:#ffffff; classDef todo fill:#6e7781,stroke:#3d4248,color:#ffffff; class sandbox_isolation,scanner_v2,analytics_dashboard,scanner_simplification,schema_deferred,agent_scope_hardening,upstream_resilience,mcp_credential_lifecycle,config_commit_consistency,review_bound_approvals,quarantine_approval_pilot done; - class ux_audit,action_log_transparency,token_bench,telemetry_identity,telemetry_v7_churn,profiles_v3 in_progress; + class ux_audit,action_log_transparency,tpa_db,token_bench,telemetry_identity,telemetry_v7_churn,profiles_v3 in_progress; class pending_call_outcome in_review; - class tpa_db,remote_access_tunnel,auto_routing_mode,tool_graph todo; + class remote_access_tunnel,auto_routing_mode,tool_graph todo; ``` **Independent epics** (16) — no cross-epic prerequisites; each stands alone: @@ -220,6 +220,36 @@ 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) in review as #1701; US2 corpus in a sibling session; US3 refresh waits on Spec 087. + +Spec: [101-tpa-db](./specs/101-tpa-db/) + +```mermaid +graph LR + tpa_db_format["Signature DB format + loader (versioned, sign…"] + tpa_db_corpus["Seed corpus: catalog known public TPA campaig…"] + tpa_db_refresh["Out-of-band refresh (offline-friendly: manual…"] + + tpa_db_format --> tpa_db_corpus + tpa_db_format --> tpa_db_refresh + + classDef in_review fill:#9a6700,stroke:#5c3d00,color:#ffffff; + classDef todo fill:#6e7781,stroke:#3d4248,color:#ffffff; + class tpa_db_format in_review; + class tpa_db_corpus,tpa_db_refresh todo; +``` + +| Task | Status | Refs | +| --- | --- | --- | +| Signature DB format + loader (versioned, signed, bundled default) | 🟡 In review | #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 | — | + +
+
🔵 Token-efficiency benchmark: measured savings, published results — In progress · P1 @@ -445,34 +475,6 @@ PR: #1536
-
-⚪ tpa-db: versioned TPA signature database for the offline scanner — Todo · 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); no implementation has started, so the epic and every child task stay todo (the pr: link is the spec, not the build). - -Spec: [101-tpa-db](./specs/101-tpa-db/) - -```mermaid -graph LR - tpa_db_format["Signature DB format + loader (versioned, sign…"] - tpa_db_corpus["Seed corpus: catalog known public TPA campaig…"] - tpa_db_refresh["Out-of-band refresh (offline-friendly: manual…"] - - tpa_db_format --> tpa_db_corpus - tpa_db_format --> tpa_db_refresh - - classDef todo fill:#6e7781,stroke:#3d4248,color:#ffffff; - class tpa_db_format,tpa_db_corpus,tpa_db_refresh todo; -``` - -| Task | Status | Refs | -| --- | --- | --- | -| Signature DB format + loader (versioned, signed, bundled default) | ⚪ Todo | — | -| 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 | — | - -
-
⚪ Auto routing mode: budget-fitted tool surface per session (spec 104) — Todo · P1 @@ -1006,6 +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 | 28/30 (93%) | [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/) | | @@ -1015,7 +1018,6 @@ graph LR | Planning/docs truth automation | In progress | P2 | — | | | | Discovery-quality eval harness (Spec 065 second half) | In progress | P3 | — | [065-evaluation-foundation](./specs/065-evaluation-foundation/) | | | Spec 114 typed pending-call outcome + call correlation for long-running stdio tool calls | In review | P2 | — | | #1536 | -| tpa-db: versioned TPA signature database for the offline scanner | Todo | P1 | 28/30 (93%) | [101-tpa-db](./specs/101-tpa-db/) | | | Auto routing mode: budget-fitted tool surface per session (spec 104) | Todo | P1 | — | [104-auto-routing-mode](./specs/104-auto-routing-mode/) | | | Windows native tray app `MCP-43` | Todo | P2 | — | | | | Remote access tunnel (feature-flagged MVP, spec 089) | Todo | P2 | — | [089-remote-access-tunnel](./specs/089-remote-access-tunnel/) | | diff --git a/roadmap.yaml b/roadmap.yaml index b9f34e5d1..9cd49307c 100644 --- a/roadmap.yaml +++ b/roadmap.yaml @@ -341,15 +341,16 @@ epics: - id: tpa-db title: "tpa-db: versioned TPA signature database for the offline scanner" - status: todo + status: in_progress priority: P1 spec: specs/101-tpa-db depends_on: [scanner-simplification] - note: "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); no implementation has started, so the epic and every child task stay todo (the pr: link is the spec, not the build)." + note: "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) in review as #1701; US2 corpus in a sibling session; US3 refresh waits on Spec 087." tasks: - id: tpa-db-format title: "Signature DB format + loader (versioned, signed, bundled default)" - status: todo + status: in_review + pr: "#1701" depends_on: [] - id: tpa-db-corpus title: "Seed corpus: catalog known public TPA campaigns/patterns into the DB" From d793e90941d71f0e856d30034a12e13f03892c56 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 15:51:33 +0300 Subject: [PATCH 21/35] fix(security): tpa-db opens no-follow non-blocking and sets the sidecar mode on the descriptor Sol 6.1 round 2: a FIFO swapped in between the lstat and a plain open blocked forever before any check ran, and writeReplace chmod-ed the temp file by name, which an attacker who can write the bundle directory could swap for a symlink to the private key. Unix opens now use O_NOFOLLOW|O_NONBLOCK and check the descriptor; the mode is set via the open temp file. Related #938 --- cmd/tpa-db/main.go | 21 +++++++++++++----- cmd/tpa-db/open_other.go | 12 +++++++++++ cmd/tpa-db/open_unix.go | 38 +++++++++++++++++++++++++++++++++ cmd/tpa-db/open_unix_test.go | 41 ++++++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 cmd/tpa-db/open_other.go create mode 100644 cmd/tpa-db/open_unix.go create mode 100644 cmd/tpa-db/open_unix_test.go diff --git a/cmd/tpa-db/main.go b/cmd/tpa-db/main.go index c3f0d0951..9e09d6490 100644 --- a/cmd/tpa-db/main.go +++ b/cmd/tpa-db/main.go @@ -299,6 +299,10 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int { return 0 } +// beforeOpenHook is a test-only seam called between readBounded's lstat and +// its open, so a test can swap the path in that window. +var beforeOpenHook func(path string) + // readBounded reads a regular file of at most limit bytes. The bundle and its // sidecar may sit in a directory someone else can write, so a symlink, FIFO or // device is refused outright (a FIFO would block the read forever) and the @@ -312,7 +316,10 @@ func readBounded(path string, limit int64) ([]byte, error) { if !pre.Mode().IsRegular() { return nil, fmt.Errorf("%s is not a regular file", path) } - f, err := os.Open(path) //nolint:gosec // operator-supplied path; type-checked above and re-checked on the descriptor below + if beforeOpenHook != nil { + beforeOpenHook(path) + } + f, err := openRegular(path) if err != nil { return nil, err } @@ -346,21 +353,25 @@ func writeReplace(dest string, data []byte, perm os.FileMode) error { } name := tmp.Name() cleanup := func() { _ = os.Remove(name) } - if _, err := tmp.Write(data); err != nil { + // Set the mode on the DESCRIPTOR: a chmod by name would follow whatever + // an attacker who can write this directory renamed into the temp name's + // place (a symlink to the private key) — Sol 6.1 review, round 2. + if err := tmp.Chmod(perm); err != nil { _ = tmp.Close() cleanup() return err } - if err := tmp.Sync(); err != nil { + if _, err := tmp.Write(data); err != nil { _ = tmp.Close() cleanup() return err } - if err := tmp.Close(); err != nil { + if err := tmp.Sync(); err != nil { + _ = tmp.Close() cleanup() return err } - if err := os.Chmod(name, perm); err != nil { + if err := tmp.Close(); err != nil { cleanup() return err } diff --git a/cmd/tpa-db/open_other.go b/cmd/tpa-db/open_other.go new file mode 100644 index 000000000..718b01983 --- /dev/null +++ b/cmd/tpa-db/open_other.go @@ -0,0 +1,12 @@ +//go:build !unix + +package main + +import "os" + +// openRegular opens path for reading. There are no FIFOs to block on here; +// readBounded's lstat and descriptor identity checks refuse anything that is +// not a regular file. +func openRegular(path string) (*os.File, error) { + return os.Open(path) //nolint:gosec // operator-supplied path; type-checked by readBounded +} diff --git a/cmd/tpa-db/open_unix.go b/cmd/tpa-db/open_unix.go new file mode 100644 index 000000000..93e7665b6 --- /dev/null +++ b/cmd/tpa-db/open_unix.go @@ -0,0 +1,38 @@ +//go:build unix + +package main + +import ( + "errors" + "fmt" + "os" + "syscall" +) + +// openRegular opens path for reading without following a final symlink and +// without blocking on a FIFO (O_NONBLOCK makes open(2) of a writer-less FIFO +// return instead of waiting), then refuses anything the DESCRIPTOR says is not +// a regular file before clearing O_NONBLOCK for the read. +func openRegular(path string) (*os.File, error) { + fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK|syscall.O_CLOEXEC, 0) + if err != nil { + if errors.Is(err, syscall.ELOOP) || errors.Is(err, syscall.EMLINK) { + return nil, fmt.Errorf("%s is not a regular file (symlink)", path) + } + return nil, &os.PathError{Op: "open", Path: path, Err: err} + } + var st syscall.Stat_t + if err := syscall.Fstat(fd, &st); err != nil { + _ = syscall.Close(fd) + return nil, &os.PathError{Op: "fstat", Path: path, Err: err} + } + if st.Mode&syscall.S_IFMT != syscall.S_IFREG { + _ = syscall.Close(fd) + return nil, fmt.Errorf("%s is not a regular file", path) + } + if err := syscall.SetNonblock(fd, false); err != nil { + _ = syscall.Close(fd) + return nil, &os.PathError{Op: "setnonblock", Path: path, Err: err} + } + return os.NewFile(uintptr(fd), path), nil +} diff --git a/cmd/tpa-db/open_unix_test.go b/cmd/tpa-db/open_unix_test.go new file mode 100644 index 000000000..4538a5fa4 --- /dev/null +++ b/cmd/tpa-db/open_unix_test.go @@ -0,0 +1,41 @@ +//go:build unix + +package main + +import ( + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +// A path swapped for a never-written FIFO between the lstat and the open must +// be refused without blocking: a plain open(2) of a FIFO blocks until a writer +// appears, before any post-open check can run (Sol 6.1 review, round 2). +func TestReadBoundedRefusesAFIFOSwappedInBeforeOpen(t *testing.T) { + p := filepath.Join(t.TempDir(), "bundle.json") + if err := os.WriteFile(p, []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + prev := beforeOpenHook + beforeOpenHook = func(path string) { + _ = os.Remove(path) + if err := syscall.Mkfifo(path, 0o600); err != nil { + t.Errorf("mkfifo: %v", err) + } + } + t.Cleanup(func() { beforeOpenHook = prev }) + + done := make(chan error, 1) + go func() { _, err := readBounded(p, 1024); done <- err }() + select { + case err := <-done: + if err == nil || !strings.Contains(err.Error(), "not a regular file") { + t.Fatalf("want a not-regular refusal, got %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("readBounded blocked on a FIFO swapped in before the open") + } +} From 55489c7eac2085d048f5c98cad90901d32a3aa04 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 15:54:11 +0300 Subject: [PATCH 22/35] fix(webui): Spec 101 US1 surface the signature bundle status on Review and Home /security redirects to /review (Spec 109), so the bundle status panel and degraded-coverage banner in Security.vue were unreachable. Extract them into SignatureBundleStatus, mount it (folded unless degraded) on Review, and show a role=alert on Home when scan-mode auto-approval is suspended, with the reason and a link to /review. The signature_bundle rides the shared useSecurityScannerStatus overview fetch; nothing renders when it is unavailable. Related #938 --- .../security/SignatureBundlePanelBody.vue | 59 ++++++++++ .../security/SignatureBundleStatus.vue | 52 +++++++++ .../composables/useSecurityScannerStatus.ts | 8 ++ frontend/src/views/Home.vue | 25 +++++ frontend/src/views/Review.vue | 6 + frontend/src/views/Security.vue | 65 +---------- .../unit/home-signature-degraded.spec.ts | 105 ++++++++++++++++++ frontend/tests/unit/review-queue.spec.ts | 7 +- .../unit/review-route-keeps-selection.spec.ts | 3 +- .../unit/review-signature-bundle.spec.ts | 95 ++++++++++++++++ .../security-scanner-status-bundle.spec.ts | 37 ++++++ 11 files changed, 396 insertions(+), 66 deletions(-) create mode 100644 frontend/src/components/security/SignatureBundlePanelBody.vue create mode 100644 frontend/src/components/security/SignatureBundleStatus.vue create mode 100644 frontend/tests/unit/home-signature-degraded.spec.ts create mode 100644 frontend/tests/unit/review-signature-bundle.spec.ts create mode 100644 frontend/tests/unit/security-scanner-status-bundle.spec.ts diff --git a/frontend/src/components/security/SignatureBundlePanelBody.vue b/frontend/src/components/security/SignatureBundlePanelBody.vue new file mode 100644 index 000000000..963dc0335 --- /dev/null +++ b/frontend/src/components/security/SignatureBundlePanelBody.vue @@ -0,0 +1,59 @@ + + + diff --git a/frontend/src/components/security/SignatureBundleStatus.vue b/frontend/src/components/security/SignatureBundleStatus.vue new file mode 100644 index 000000000..8f98a71b7 --- /dev/null +++ b/frontend/src/components/security/SignatureBundleStatus.vue @@ -0,0 +1,52 @@ + + + diff --git a/frontend/src/composables/useSecurityScannerStatus.ts b/frontend/src/composables/useSecurityScannerStatus.ts index 19321b75d..784d14986 100644 --- a/frontend/src/composables/useSecurityScannerStatus.ts +++ b/frontend/src/composables/useSecurityScannerStatus.ts @@ -10,12 +10,17 @@ import { ref } from 'vue' import api from '@/services/api' import { useAuthStore } from '@/stores/auth' +import type { SignatureBundle } from '@/utils/signatureBundle' const enabledCount = ref(null) const dockerAvailable = ref(true) const loaded = ref(false) const totalFindings = ref(0) const totalScans = ref(0) +// Spec 101 FR-020: the signature_bundle descriptor from the same overview +// response, shared so Review and Home show the bundle state without a second +// fetch. Null until loaded, on an older daemon, or for a scoped session. +const signatureBundle = ref(null) let inflight: Promise | null = null // Spec 077 US4 (MCP-2207): the backend now collapses the per-scanner @@ -49,6 +54,8 @@ export async function refreshSecurityScannerStatus(): Promise { const fbs = data.findings_by_severity ?? {} totalFindings.value = typeof fbs.total === 'number' ? fbs.total : 0 totalScans.value = typeof data.total_scans === 'number' ? data.total_scans : 0 + const bundle = data.signature_bundle + signatureBundle.value = bundle && typeof bundle === 'object' ? bundle : null loaded.value = true } catch { // On error, keep current values; the UI will fall back to dockerAvailable @@ -79,6 +86,7 @@ export function useSecurityScannerStatus() { loaded, totalFindings, totalScans, + signatureBundle, /** True when at least one scanner is enabled and docker is available. */ hasEnabledScanners: () => (enabledCount.value ?? 0) > 0, /** True when at least one scan has completed (ever). */ diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue index c899b8a48..e8c83ae3f 100644 --- a/frontend/src/views/Home.vue +++ b/frontend/src/views/Home.vue @@ -6,6 +6,22 @@ + + + @@ -427,6 +443,7 @@ import type { Hint } from '@/components/CollapsibleHintsPanel.vue' import type { ClientStatus } from '@/types' import { liveClientsFromSessions } from '@/utils/sessionLabel' import { formatRelativeTime } from '@/utils/activity' +import { formatSignatureBundle } from '@/utils/signatureBundle' const serversStore = useServersStore() const systemStore = useSystemStore() @@ -486,8 +503,16 @@ const { totalFindings: securityTotalFindings, totalScans: securityTotalScans, loaded: securityScannerLoaded, + signatureBundle: securitySignatureBundle, } = useSecurityScannerStatus() +// Spec 101 FR-020: non-null only while signature coverage is degraded. +const bundleDegraded = computed(() => + authStore.principalKind === 'tenant' + ? null + : formatSignatureBundle(securitySignatureBundle?.value)?.degraded ?? null +) + const loadSecurityStatus = async () => { // Spec 107 FR-041 / T088: /docker/status and /config are admin-only core // doors — a tenant principal's dashboard has no docker/quarantine chip to diff --git a/frontend/src/views/Review.vue b/frontend/src/views/Review.vue index 813de7e15..c9d75eb9a 100644 --- a/frontend/src/views/Review.vue +++ b/frontend/src/views/Review.vue @@ -24,6 +24,8 @@
No reviews match this view.
+ +
@@ -36,7 +38,11 @@ import type { ReviewQueueResponse } from '@/types' import ReviewScreen from '@/components/ReviewScreen.vue' import ScanHistory from '@/components/ScanHistory.vue' import ReviewQueueList from '@/components/ReviewQueueList.vue' +import SignatureBundleStatus from '@/components/security/SignatureBundleStatus.vue' +import { useSecurityScannerStatus } from '@/composables/useSecurityScannerStatus' import { isActiveBlocker, reviewQueueScopeText, sortReviewRows, summarizeReviewQueue, type ReviewSort } from '@/utils/reviewQueue' +// Shared with Home/Security: one /security/overview fetch; renders nothing when unavailable (scoped session, older daemon). +const { signatureBundle } = useSecurityScannerStatus() const route = useRoute(); const scopeQuery = useScopeQuery('review'); const queue = ref(null); const loading = ref(false); const error = ref('') const selectedServer = computed(() => typeof route.params.server === 'string' ? route.params.server : '') const change = computed(() => scopeQuery.state.change ?? '') diff --git a/frontend/src/views/Security.vue b/frontend/src/views/Security.vue index 08daf51b3..f5cd20544 100644 --- a/frontend/src/views/Security.vue +++ b/frontend/src/views/Security.vue @@ -170,52 +170,8 @@
- - - -
-
- Signature bundle - - {{ row.label }}: {{ row.value }} - - pinned - rolled back -
-
- Fingerprint - {{ signatureBundle.shortFingerprint }} - -
-
- {{ signatureBundle.fullDigest }} - -
-
- Last refused candidate: {{ signatureBundle.lastRejection.label }} - — {{ signatureBundle.lastRejection.detail }} - ({{ signatureBundle.lastRejection.at }}). - The previous bundle is still serving. -
-
+ + @@ -580,6 +536,7 @@ import { refreshSecurityScannerStatus } from '@/composables/useSecurityScannerSt import { useSystemStore } from '@/stores/system' import { scanReportPath } from '@/utils/serverRoute' import { formatSignatureBundle } from '@/utils/signatureBundle' +import SignatureBundleStatus from '@/components/security/SignatureBundleStatus.vue' import { deepScanSummary, enabledDockerScanners, scanAllTooltip, scannerWontRun } from './security/deepScanState' const systemStore = useSystemStore() @@ -664,22 +621,6 @@ const totalFindings = computed(() => overview.value?.findings_by_severity?.total // simply absent rather than rendering a misleading zero. const signatureBundle = computed(() => formatSignatureBundle(overview.value?.signature_bundle)) -// Full digest is the rollback input (Spec 101 FR-010), so it is available in -// full on demand rather than cluttering the default view. -const showFullDigest = ref(false) -const digestCopied = ref(false) -async function copyFullDigest() { - const text = signatureBundle.value?.fullDigest || '' - if (!text) return - try { - await navigator.clipboard.writeText(text) - digestCopied.value = true - setTimeout(() => { digestCopied.value = false }, 1500) - } catch { - // Clipboard blocked: the digest is already visible and selectable. - } -} - // Docker isolation state — populated from /api/v1/config + /api/v1/servers. const isolationEnabled = ref(false) const stdioServerCount = ref(0) diff --git a/frontend/tests/unit/home-signature-degraded.spec.ts b/frontend/tests/unit/home-signature-degraded.spec.ts new file mode 100644 index 000000000..30f481767 --- /dev/null +++ b/frontend/tests/unit/home-signature-degraded.spec.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { shallowMount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { createRouter, createMemoryHistory } from 'vue-router' + +// Spec 101 FR-020: a suspended scan-mode auto-approval must be visible on Home, +// with the plain-language reason and a link to /review. Nothing when healthy. + +const bundleRef = vi.hoisted(() => ({ current: null as unknown })) + +vi.mock('@/services/api', () => { + const ok = (data: unknown = null) => vi.fn().mockResolvedValue({ success: true, data }) + const fakeEventSource = { + onopen: null, onmessage: null, onerror: null, + addEventListener() {}, removeEventListener() {}, close() {}, + } + const base: Record = { + getAttention: ok({ count: 0, items: [] }), + getServers: ok({ servers: [{ name: 'notes', enabled: true, connected: true, tool_count: 3 }] }), + getActivitySummary: ok({ call_count: 5, blocked_count: 1, call_error_count: 2 }), + createEventSource: vi.fn(() => fakeEventSource), + hasAPIKey: vi.fn(() => true), + getAPIKeyPreview: vi.fn(() => 'test…'), + onAuthError: vi.fn(() => () => {}), + } + return { + default: new Proxy(base, { + get(target: Record, prop: string) { + if (prop in target) return target[prop] + target[prop] = ok() + return target[prop] + }, + }), + } +}) + +vi.mock('@/composables/useSecurityScannerStatus', async () => { + const { ref } = await import('vue') + return { + refreshSecurityScannerStatus: vi.fn().mockResolvedValue(undefined), + useSecurityScannerStatus: () => ({ + totalFindings: ref(0), + totalScans: ref(0), + loaded: ref(true), + signatureBundle: ref(bundleRef.current), + }), + } +}) + +import Home from '@/views/Home.vue' + +class FakeEventSource { + close() {} + addEventListener() {} + onmessage: ((e: unknown) => void) | null = null + onerror: ((e: unknown) => void) | null = null +} + +async function mountHome() { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'home', component: Home }, + { path: '/review', name: 'review', component: { template: '
' } }, + { path: '/:pathMatch(.*)*', name: 'other', component: { template: '
' } }, + ], + }) + router.push('/') + await router.isReady() + const wrapper = shallowMount(Home, { + global: { plugins: [createPinia(), router], stubs: { RouterLink: false } }, + }) + await flushPromises() + return wrapper +} + +describe('Home degraded signature coverage (Spec 101 FR-020)', () => { + beforeEach(() => { + setActivePinia(createPinia()) + ;(globalThis as unknown as { EventSource: unknown }).EventSource = FakeEventSource + }) + + it('shows a role=alert with the reason and a /review link when degraded', async () => { + bundleRef.current = { runnable_rules: 5, coverage_degraded: true, degraded_reason: 'embedded_below_watermark' } + const wrapper = await mountHome() + const alert = wrapper.get('[data-test="home-signature-degraded"]') + expect(alert.attributes('role')).toBe('alert') + expect(alert.text().toLowerCase()).toContain('auto-approval is suspended') + expect(alert.text()).toContain('embedded_below_watermark') + expect(alert.text().toLowerCase()).toContain('older than a signed bundle') + expect(wrapper.get('[data-test="home-signature-degraded-link"]').attributes('href')).toBe('/review') + }) + + it('shows nothing when coverage is not degraded', async () => { + bundleRef.current = { runnable_rules: 5, coverage_degraded: false } + const wrapper = await mountHome() + expect(wrapper.find('[data-test="home-signature-degraded"]').exists()).toBe(false) + }) + + it('shows nothing when the overview is unavailable', async () => { + bundleRef.current = null + const wrapper = await mountHome() + expect(wrapper.find('[data-test="home-signature-degraded"]').exists()).toBe(false) + }) +}) diff --git a/frontend/tests/unit/review-queue.spec.ts b/frontend/tests/unit/review-queue.spec.ts index e38227eed..0a29278d8 100644 --- a/frontend/tests/unit/review-queue.spec.ts +++ b/frontend/tests/unit/review-queue.spec.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import { createRouter, createMemoryHistory } from 'vue-router' +import { createPinia } from 'pinia' import Review from '@/views/Review.vue' import api from '@/services/api' @@ -14,7 +15,7 @@ describe('Review queue (T088)', () => { it('lists servers and refreshes after review.changed', async () => { const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/review', component: Review }, { path: '/review/:server', component: Review }] }) await router.push('/review'); await router.isReady() - const wrapper = mount(Review, { global: { plugins: [router] } }); await flushPromises() + const wrapper = mount(Review, { global: { plugins: [createPinia(), router] } }); await flushPromises() expect(wrapper.get('[data-test="servers-review-row-alpha"]').text()).toContain('1 new') expect(wrapper.get('[data-test="servers-review-link-alpha"]').attributes('href')).toContain('/review/alpha') window.dispatchEvent(new Event('mcpproxy:review-changed')); await flushPromises() @@ -28,7 +29,7 @@ describe('Review queue (T088)', () => { ] } }) const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/review', component: Review }, { path: '/review/:server', component: Review }] }) await router.push('/review?server=alpha'); await router.isReady() - const wrapper = mount(Review, { global: { plugins: [router] } }); await flushPromises() + const wrapper = mount(Review, { global: { plugins: [createPinia(), router] } }); await flushPromises() expect(wrapper.find('[data-test="servers-review-row-alpha"]').exists()).toBe(true) expect(wrapper.find('[data-test="servers-review-row-bravo"]').exists()).toBe(false) expect(wrapper.find('[data-test="review-screen"]').exists()).toBe(false) @@ -46,7 +47,7 @@ describe('Review queue (T088)', () => { ;(api.getReviewQueue as any).mockResolvedValue({ success: true, data }) const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/review', component: Review }, { path: '/review/:server', component: Review }] }) await router.push(path); await router.isReady() - const wrapper = mount(Review, { global: { plugins: [router] } }); await flushPromises() + const wrapper = mount(Review, { global: { plugins: [createPinia(), router] } }); await flushPromises() return { wrapper, router } } const names = (w: any) => w.findAll('[data-test^="servers-review-row-"]').map((r: any) => r.attributes('data-test').replace('servers-review-row-', '')) diff --git a/frontend/tests/unit/review-route-keeps-selection.spec.ts b/frontend/tests/unit/review-route-keeps-selection.spec.ts index 2e5cac8b0..5d45dc4c6 100644 --- a/frontend/tests/unit/review-route-keeps-selection.spec.ts +++ b/frontend/tests/unit/review-route-keeps-selection.spec.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import { createRouter, createMemoryHistory } from 'vue-router' +import { createPinia } from 'pinia' import Review from '@/views/Review.vue' import api from '@/services/api' import type { ReviewTool } from '@/types' @@ -18,7 +19,7 @@ const tool = (name: string, extra: Partial = {}): ReviewTool => ({ async function mountRoute() { const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/review', component: Review }, { path: '/review/:server', component: Review }] }) await router.push('/review/fixture'); await router.isReady() - const wrapper = mount(Review, { global: { plugins: [router], stubs: { RouterLink: { template: '' } } } }) + const wrapper = mount(Review, { global: { plugins: [createPinia(), router], stubs: { RouterLink: { template: '' } } } }) await flushPromises() return wrapper } diff --git a/frontend/tests/unit/review-signature-bundle.spec.ts b/frontend/tests/unit/review-signature-bundle.spec.ts new file mode 100644 index 000000000..94e7bc9e3 --- /dev/null +++ b/frontend/tests/unit/review-signature-bundle.spec.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { createRouter, createMemoryHistory } from 'vue-router' + +// Spec 101 FR-020 / #938: /security redirects to /review, so the Review page is +// where an operator must be able to see the signature bundle state and, when +// coverage is degraded, why scan-mode auto-approval is suspended. + +const FULL = 'c0ffee012345' + 'd'.repeat(52) +const bundleRef = vi.hoisted(() => ({ current: null as unknown })) + +vi.mock('@/services/api', () => ({ + default: { getReviewQueue: vi.fn(), listScanHistory: vi.fn() }, +})) +vi.mock('@/composables/useSecurityScannerStatus', async () => { + const { ref } = await import('vue') + return { + useSecurityScannerStatus: () => ({ signatureBundle: ref(bundleRef.current) }), + refreshSecurityScannerStatus: vi.fn(), + } +}) + +import Review from '@/views/Review.vue' +import api from '@/services/api' + +const healthy = { + source: 'file', + runnable_rules: 12, + fingerprint: FULL.slice(0, 12), + full_digest: FULL, + sequence: '42', + authority: 'operator', + trust_epoch: 3, + signature_verified: true, + staleness_days: 4, + coverage_degraded: false, +} + +async function open() { + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/review', component: Review }, { path: '/review/:server', component: Review }], + }) + await router.push('/review') + await router.isReady() + const wrapper = mount(Review, { global: { plugins: [createPinia(), router] } }) + await flushPromises() + return wrapper +} + +describe('Review page signature bundle (Spec 101 FR-020)', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + ;(api.getReviewQueue as any).mockResolvedValue({ success: true, data: { count: 0, servers: [] } }) + ;(api.listScanHistory as any).mockResolvedValue({ success: true, data: { scans: [], total: 0 } }) + bundleRef.current = healthy + }) + + it('renders a folded bundle section with identity and no alert when healthy', async () => { + const wrapper = await open() + const details = wrapper.get('[data-test="bundle-details"]') + expect(details.attributes('open')).toBeUndefined() + expect(wrapper.get('[data-test="bundle-summary-line"]').text()).toContain(FULL.slice(0, 12)) + const panel = wrapper.get('[data-test="bundle-status-panel"]') + expect(panel.text()).toContain('42') + expect(panel.text()).toContain('operator') + expect(panel.text()).toContain('verified') + expect(wrapper.find('[data-test="bundle-degraded-alert"]').exists()).toBe(false) + }) + + it('offers the full digest on demand', async () => { + const wrapper = await open() + expect(wrapper.find('[data-test="bundle-full-digest"]').exists()).toBe(false) + await wrapper.get('[data-test="bundle-digest-toggle"]').trigger('click') + expect(wrapper.get('[data-test="bundle-full-digest"]').text()).toBe(FULL) + }) + + it('raises a role=alert banner with the reason when coverage is degraded', async () => { + bundleRef.current = { ...healthy, coverage_degraded: true, degraded_reason: 'unsigned_external_active' } + const wrapper = await open() + const alert = wrapper.get('[data-test="bundle-degraded-alert"]') + expect(alert.attributes('role')).toBe('alert') + expect(alert.text().toLowerCase()).toContain('unsigned') + expect(alert.text().toLowerCase()).toContain('auto-approval') + }) + + it('renders nothing when the overview is unavailable (scoped session / older daemon)', async () => { + bundleRef.current = null + const wrapper = await open() + expect(wrapper.find('[data-test="signature-bundle-status"]').exists()).toBe(false) + expect(wrapper.find('[data-test="bundle-degraded-alert"]').exists()).toBe(false) + }) +}) diff --git a/frontend/tests/unit/security-scanner-status-bundle.spec.ts b/frontend/tests/unit/security-scanner-status-bundle.spec.ts new file mode 100644 index 000000000..4ab25fa73 --- /dev/null +++ b/frontend/tests/unit/security-scanner-status-bundle.spec.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +// The shared composable carries signature_bundle from the one +// /security/overview fetch, so Review and Home need no second request. + +vi.mock('@/services/api', () => ({ default: { getSecurityOverview: vi.fn() } })) + +describe('useSecurityScannerStatus signatureBundle', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.resetModules() + }) + + it('exposes the signature_bundle from the overview and shares one ref', async () => { + const api = (await import('@/services/api')).default as any + api.getSecurityOverview.mockResolvedValue({ + success: true, + data: { scanners_enabled: 1, signature_bundle: { coverage_degraded: true, degraded_reason: 'x' } }, + }) + const mod = await import('@/composables/useSecurityScannerStatus') + await mod.refreshSecurityScannerStatus() + const a = mod.useSecurityScannerStatus() + const b = mod.useSecurityScannerStatus() + expect(a.signatureBundle.value).toEqual({ coverage_degraded: true, degraded_reason: 'x' }) + expect(b.signatureBundle).toBe(a.signatureBundle) + expect(api.getSecurityOverview).toHaveBeenCalledTimes(1) + }) + + it('stays null when the overview carries no bundle', async () => { + const api = (await import('@/services/api')).default as any + api.getSecurityOverview.mockResolvedValue({ success: true, data: { scanners_enabled: 1 } }) + const mod = await import('@/composables/useSecurityScannerStatus') + await mod.refreshSecurityScannerStatus() + expect(mod.useSecurityScannerStatus().signatureBundle.value).toBeNull() + }) +}) From 39113f8d08b268a27ac6a24c5b8e4619aaa51b3d Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 15:58:21 +0300 Subject: [PATCH 23/35] fix(security): Spec 101 US1 anchor LKG files to a non-symlinked directory Every directory-level operation on /tpa-lkg (create, temp file, write, rename, enumerate, delete, fsync) now goes through an os.Root opened on the real directory. A tpa-lkg that is a symlink, reparse point or regular file is refused (no write, no reconcile), and the lstat'd identity is compared with the opened root to close the lstat/open race. The data directory is also fsynced on every activation, not only the one that created tpa-lkg, so a failed parent fsync cannot be skipped by the retry (FR-011a). Related #938 --- internal/security/scanner/tpa_bundle_lkg.go | 187 ++++++++++++++---- .../scanner/tpa_bundle_lkg_anchor_test.go | 128 ++++++++++++ .../scanner/tpa_bundle_lkg_write_test.go | 11 +- 3 files changed, 281 insertions(+), 45 deletions(-) create mode 100644 internal/security/scanner/tpa_bundle_lkg_anchor_test.go diff --git a/internal/security/scanner/tpa_bundle_lkg.go b/internal/security/scanner/tpa_bundle_lkg.go index f990f0e75..82d7d6a7d 100644 --- a/internal/security/scanner/tpa_bundle_lkg.go +++ b/internal/security/scanner/tpa_bundle_lkg.go @@ -3,6 +3,8 @@ package scanner import ( "bytes" "context" + "crypto/rand" + "encoding/hex" "errors" "fmt" "os" @@ -68,10 +70,82 @@ func lkgSidecarKey(bundleDigest, sidecarDigest string) string { // state recorded for it. var errLKGSubstituted = errors.New("last-known-good file does not match its recorded digest") +// lkgAnchor holds the data directory and the tpa-lkg directory as os.Root +// handles, so every directory-level operation (temp file, write, rename, +// enumerate, delete) stays inside the real tpa-lkg directory even if an +// attacker swaps a path component for a symlink while the process runs. +type lkgAnchor struct { + data *os.Root // the data directory (fsynced so tpa-lkg's own entry is durable) + dir *os.Root // /tpa-lkg +} + +func (a *lkgAnchor) Close() { + _ = a.dir.Close() + _ = a.data.Close() +} + +// lkgOpenAnchor opens /tpa-lkg as a root. It refuses (error, nothing +// touched) when tpa-lkg exists and is not a real directory: a symlink, a +// Windows reparse point or a regular file. With create it makes the directory +// when absent; without it an absent directory is os.ErrNotExist. The identity +// of the directory that was lstat-ed is compared with the one actually opened, +// so a swap between the two steps is caught too. +func lkgOpenAnchor(dataDir string, create bool) (*lkgAnchor, error) { + if create { + if err := os.MkdirAll(dataDir, 0o700); err != nil { + return nil, err + } + } + data, err := os.OpenRoot(dataDir) + if err != nil { + return nil, err + } + dir, err := lkgOpenDirIn(data, create) + if err != nil { + _ = data.Close() + return nil, err + } + return &lkgAnchor{data: data, dir: dir}, nil +} + +func lkgOpenDirIn(data *os.Root, create bool) (*os.Root, error) { + info, err := data.Lstat(lkgDirName) + if errors.Is(err, os.ErrNotExist) && create { + if mkErr := data.Mkdir(lkgDirName, 0o700); mkErr != nil && !errors.Is(mkErr, os.ErrExist) { + return nil, mkErr + } + info, err = data.Lstat(lkgDirName) + } + if err != nil { + return nil, err + } + if !info.IsDir() { + return nil, fmt.Errorf("%s is not a regular directory (mode %s): refusing to use it", lkgDirName, info.Mode().Type()) + } + dir, err := data.OpenRoot(lkgDirName) + if err != nil { + return nil, err + } + opened, err := dir.Stat(".") + if err != nil { + _ = dir.Close() + return nil, err + } + if !os.SameFile(info, opened) { + _ = dir.Close() + return nil, fmt.Errorf("%s changed while it was being opened: refusing to use it", lkgDirName) + } + return dir, nil +} + // lkgWrite makes one bundle (and its sidecar, when signed) durable: // -// temp file in the same directory -> write -> fsync -> close -> rename to the -// final name -> fsync the directory. +// fsync the data directory -> temp file in tpa-lkg -> write -> fsync -> close +// -> rename to the final name -> fsync tpa-lkg. +// +// The data directory is fsynced on EVERY call, not only when this call created +// tpa-lkg: a parent fsync that failed right after the creation refuses that +// activation, and the retry finds the directory already present (FR-011a). // // It is idempotent: a final file that already holds the same bytes is left // alone. Only after lkgWrite returns may the state commit and the publish @@ -80,50 +154,70 @@ func lkgWrite(dataDir, digest string, bundle, sidecar []byte, signed bool) error if !digestHexPattern.MatchString(digest) { return fmt.Errorf("refusing to name an LKG file after %q", digest) } - dir := lkgDirPath(dataDir) - _, statErr := os.Lstat(dir) - created := errors.Is(statErr, os.ErrNotExist) - if err := os.MkdirAll(dir, 0o700); err != nil { + a, err := lkgOpenAnchor(dataDir, true) + if err != nil { return err } - if created { - // A new directory's own entry lives in its parent: fsync the parent - // too, or a power loss after the state commit could leave an Active - // record whose tpa-lkg/ never reached the disk (FR-011a). - if err := lkgSyncDirFn(dataDir); err != nil { - return err - } + defer a.Close() + if err := lkgSyncDirFn(dataDir, a.data); err != nil { + return err } - if err := lkgWriteFile(dir, digest+".json", bundle); err != nil { + dir := lkgDirPath(dataDir) + if err := lkgWriteFile(a.dir, dir, digest+".json", bundle); err != nil { return err } if signed { - if err := lkgWriteFile(dir, lkgSidecarName(digest, digestHex(sidecar)), sidecar); err != nil { + if err := lkgWriteFile(a.dir, dir, lkgSidecarName(digest, digestHex(sidecar)), sidecar); err != nil { return err } } - return lkgSyncDirFn(dir) + return lkgSyncDirFn(dir, a.dir) +} + +// lkgTempFile creates a fresh, exclusively-created temp file inside the +// anchored directory. +func lkgTempFile(root *os.Root) (*os.File, string, error) { + var lastErr error + for range 8 { + var rnd [8]byte + if _, err := rand.Read(rnd[:]); err != nil { + return nil, "", err + } + name := lkgTempPrefix + hex.EncodeToString(rnd[:]) + f, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err == nil { + return f, name, nil + } + if !errors.Is(err, os.ErrExist) { + return nil, "", err + } + lastErr = err + } + return nil, "", lastErr } -// lkgWriteFile writes data to dir/name via a synced temp file and a rename. -func lkgWriteFile(dir, name string, data []byte) error { - final := filepath.Join(dir, name) +// lkgWriteFile writes data to name inside the anchored directory via a synced +// temp file and a rename. dirPath is the path of that directory, used only to +// compare an existing file through the guarded reader. +func lkgWriteFile(root *os.Root, dirPath, name string, data []byte) error { // The data directory is attacker-writable in the threat model, so an - // existing file is compared only through the descriptor-first, bounded, + // existing file is compared only when it is a regular file of the anchored + // directory, and then only through the descriptor-first, bounded, // budgeted reader (a planted FIFO or oversized file is never read raw); // anything that does not read back as exactly these bytes is replaced. - ctx, cancel := context.WithTimeout(context.Background(), bundleReadBudget) - existing, rerr := readCandidateFile(ctx, final, int64(len(data))) - cancel() - if rerr == nil && bytes.Equal(existing, data) { - return nil + if info, lerr := root.Lstat(name); lerr == nil && info.Mode().IsRegular() { + ctx, cancel := context.WithTimeout(context.Background(), bundleReadBudget) + existing, rerr := readCandidateFile(ctx, filepath.Join(dirPath, name), int64(len(data))) + cancel() + if rerr == nil && bytes.Equal(existing, data) { + return nil + } } - tmp, err := os.CreateTemp(dir, lkgTempPrefix+"*") + tmp, tmpName, err := lkgTempFile(root) if err != nil { return err } - tmpName := tmp.Name() - cleanup := func() { _ = os.Remove(tmpName) } + cleanup := func() { _ = root.Remove(tmpName) } if _, err := tmp.Write(data); err != nil { _ = tmp.Close() cleanup() @@ -138,7 +232,7 @@ func lkgWriteFile(dir, name string, data []byte) error { cleanup() return err } - if err := os.Rename(tmpName, final); err != nil { + if err := root.Rename(tmpName, name); err != nil { cleanup() return err } @@ -149,16 +243,17 @@ func lkgWriteFile(dir, name string, data []byte) error { // directories were made durable). var lkgSyncDirFn = lkgSyncDir -// lkgSyncDir fsyncs the directory so the renames are durable. Directory fsync -// is not supported on Windows (opening a directory handle for sync fails -// there); NTFS journals the rename itself, so it is skipped. -func lkgSyncDir(dir string) error { +// lkgSyncDir fsyncs the directory root names (dir is its path, for messages) +// so the renames are durable. Directory fsync is not supported on Windows +// (opening a directory handle for sync fails there); NTFS journals the rename +// itself, so it is skipped. +func lkgSyncDir(dir string, root *os.Root) error { if runtime.GOOS == "windows" { return nil } - d, err := os.Open(dir) //nolint:gosec // dataDir/tpa-lkg + d, err := root.Open(".") if err != nil { - return err + return fmt.Errorf("open %s for sync: %w", dir, err) } syncErr := d.Sync() closeErr := d.Close() @@ -239,17 +334,29 @@ func lkgReferenced(st *TPABundleState) lkgRefs { // lkgReconcile removes LKG files and leftover temp files that no live state // record references: the debris of an interrupted activation, a superseded // release, or a refused candidate. It never deletes outside the tpa-lkg -// directory, never follows a symlink (non-regular entries are skipped) and -// leaves alone any name that is not an LKG entry. It returns the removed names. +// directory (a tpa-lkg that is a symlink or any other non-directory is refused +// with an error and nothing is touched; every enumerate/delete goes through an +// os.Root anchored to the real directory), never follows a symlink +// (non-regular entries are skipped) and leaves alone any name that is not an +// LKG entry. It returns the removed names. func lkgReconcile(dataDir string, st *TPABundleState) ([]string, error) { - dir := lkgDirPath(dataDir) - entries, err := os.ReadDir(dir) + a, err := lkgOpenAnchor(dataDir, false) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil } return nil, err } + defer a.Close() + d, err := a.dir.Open(".") + if err != nil { + return nil, err + } + entries, err := d.ReadDir(-1) + _ = d.Close() + if err != nil { + return nil, err + } keep := lkgReferenced(st) var removed []string var firstErr error @@ -281,7 +388,7 @@ func lkgReconcile(dataDir string, st *TPABundleState) ([]string, error) { if !remove { continue } - if err := os.Remove(filepath.Join(dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) { + if err := a.dir.Remove(name); err != nil && !errors.Is(err, os.ErrNotExist) { if firstErr == nil { firstErr = err } diff --git a/internal/security/scanner/tpa_bundle_lkg_anchor_test.go b/internal/security/scanner/tpa_bundle_lkg_anchor_test.go new file mode 100644 index 000000000..72a2c7a63 --- /dev/null +++ b/internal/security/scanner/tpa_bundle_lkg_anchor_test.go @@ -0,0 +1,128 @@ +package scanner + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +// plantRedirect replaces /tpa-lkg with a symlink to a directory +// outside it, seeded with files an unanchored reconcile would delete. +func plantRedirect(t *testing.T) (dataDir, outside string, seeded []string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on Windows") + } + dataDir = t.TempDir() + outside = t.TempDir() + orphan := digestHex([]byte("someone else's recovery bundle")) + for _, name := range []string{orphan + ".json", lkgTempPrefix + "victim"} { + require.NoError(t, os.WriteFile(filepath.Join(outside, name), []byte("precious"), 0o600)) + seeded = append(seeded, name) + } + require.NoError(t, os.Symlink(outside, lkgDirPath(dataDir))) + return dataDir, outside, seeded +} + +func requireUntouched(t *testing.T, outside string, seeded []string) { + t.Helper() + entries, err := os.ReadDir(outside) + require.NoError(t, err) + var got []string + for _, e := range entries { + got = append(got, e.Name()) + } + require.ElementsMatch(t, seeded, got, "files outside the LKG directory must be neither deleted nor added to") + for _, name := range seeded { + b, err := os.ReadFile(filepath.Join(outside, name)) + require.NoError(t, err) + require.Equal(t, "precious", string(b)) + } +} + +// The data directory is attacker-writable (SC-012): tpa-lkg replaced by a +// symlink must not redirect the startup reconcile's deletes. +func TestLKGReconcile_RefusesASymlinkedDirectory(t *testing.T) { + dataDir, outside, seeded := plantRedirect(t) + removed, err := lkgReconcile(dataDir, nil) + require.Error(t, err) + require.Empty(t, removed) + requireUntouched(t, outside, seeded) +} + +// ...nor the writes and renames of an activation. +func TestLKGWrite_RefusesASymlinkedDirectory(t *testing.T) { + dataDir, outside, seeded := plantRedirect(t) + data := []byte(`{"bundle_version":"0.1.0"}`) + require.Error(t, lkgWrite(dataDir, digestHex(data), data, nil, false)) + requireUntouched(t, outside, seeded) +} + +func TestLKG_RefusesANonDirectory(t *testing.T) { + dataDir := t.TempDir() + require.NoError(t, os.WriteFile(lkgDirPath(dataDir), []byte("not a directory"), 0o600)) + data := []byte(`{"bundle_version":"0.1.0"}`) + require.Error(t, lkgWrite(dataDir, digestHex(data), data, nil, false)) + _, err := lkgReconcile(dataDir, nil) + require.Error(t, err) +} + +func TestLKGReconcile_AbsentDirectoryIsNotAnError(t *testing.T) { + removed, err := lkgReconcile(t.TempDir(), nil) + require.NoError(t, err) + require.Empty(t, removed) +} + +// A fresh directory (and an already-real one) keeps working and leaves no +// temp debris behind. +func TestLKGWrite_FreshDirectoryStillWorks(t *testing.T) { + dataDir := t.TempDir() + data := []byte(`{"bundle_version":"0.1.0"}`) + digest := digestHex(data) + sidecar := []byte("sidecar") + require.NoError(t, lkgWrite(dataDir, digest, data, sidecar, true)) + require.NoError(t, lkgWrite(dataDir, digest, data, sidecar, true)) + entries, err := os.ReadDir(lkgDirPath(dataDir)) + require.NoError(t, err) + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + require.ElementsMatch(t, []string{digest + ".json", lkgSidecarName(digest, digestHex(sidecar))}, names) + info, err := os.Lstat(lkgDirPath(dataDir)) + require.NoError(t, err) + require.True(t, info.IsDir()) +} + +// FR-011a: the data directory's entry for tpa-lkg is made durable on EVERY +// activation. A parent fsync that failed right after the directory was created +// must not be skipped on the retry just because the directory now exists. +func TestLKGWrite_FailedParentSyncIsRetriedBeforeSuccess(t *testing.T) { + dataDir := t.TempDir() + var parentSyncs int + failFirst := true + prev := lkgSyncDirFn + lkgSyncDirFn = func(dir string, root *os.Root) error { + if dir == dataDir { + parentSyncs++ + if failFirst { + failFirst = false + return errors.New("injected parent fsync failure") + } + } + return prev(dir, root) + } + t.Cleanup(func() { lkgSyncDirFn = prev }) + + data := []byte(`{"bundle_version":"0.1.0"}`) + digest := digestHex(data) + require.Error(t, lkgWrite(dataDir, digest, data, nil, false), "a failed parent fsync refuses the activation") + require.Equal(t, 1, parentSyncs) + + require.NoError(t, lkgWrite(dataDir, digest, data, nil, false)) + require.Equal(t, 2, parentSyncs, "the retry must sync the parent again before reporting success") +} diff --git a/internal/security/scanner/tpa_bundle_lkg_write_test.go b/internal/security/scanner/tpa_bundle_lkg_write_test.go index 62dc4696c..a91342cb2 100644 --- a/internal/security/scanner/tpa_bundle_lkg_write_test.go +++ b/internal/security/scanner/tpa_bundle_lkg_write_test.go @@ -46,14 +46,14 @@ func TestLKGWrite_ExistingFileIsReadThroughTheGuardedReader(t *testing.T) { require.Equal(t, data, got) } -// The first activation creates tpa-lkg/; its entry in the data directory must -// be made durable too, or a power loss after the state commit can leave an -// Active record whose LKG directory never reached the disk (FR-011a). +// Every activation fsyncs the data directory: the first one creates tpa-lkg/, +// and a parent fsync that failed then must be repeated by the retry, which sees +// the directory already present (FR-011a). func TestLKGWrite_FirstWriteSyncsTheParentDirectory(t *testing.T) { dataDir := t.TempDir() var synced []string prev := lkgSyncDirFn - lkgSyncDirFn = func(dir string) error { synced = append(synced, dir); return prev(dir) } + lkgSyncDirFn = func(dir string, root *os.Root) error { synced = append(synced, dir); return prev(dir, root) } t.Cleanup(func() { lkgSyncDirFn = prev }) data := []byte(`{"bundle_version":"0.1.0"}`) @@ -64,5 +64,6 @@ func TestLKGWrite_FirstWriteSyncsTheParentDirectory(t *testing.T) { synced = nil other := []byte(`{"bundle_version":"0.1.1"}`) require.NoError(t, lkgWrite(dataDir, digestHex(other), other, nil, false)) - require.NotContains(t, synced, dataDir, "an existing tpa-lkg needs no parent fsync") + require.Contains(t, synced, dataDir, "every activation fsyncs the parent, not only the one that created tpa-lkg") + require.Contains(t, synced, lkgDirPath(dataDir)) } From 860d5ce9200d78a01b58875ff27b19aa2a024076 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 16:03:32 +0300 Subject: [PATCH 24/35] fix(security): tpa-db verify reports sidecar_too_large for an oversized sidecar Sol 6.1 round 3: the bounded read refused the oversized sidecar before ParseSidecar, so stdout carried no contract code. The size refusal is now routed through the same code path as every other rejection. Related #938 --- cmd/tpa-db/main.go | 10 +++++++++- cmd/tpa-db/main_test.go | 5 +++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/cmd/tpa-db/main.go b/cmd/tpa-db/main.go index 9e09d6490..ae2d5c8b8 100644 --- a/cmd/tpa-db/main.go +++ b/cmd/tpa-db/main.go @@ -268,6 +268,10 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int { // it (FR-008): it is the input verification depends on. raw, err := readBounded(*bundlePath+".sig", bundlesig.MaxSidecarBytes) if err != nil { + if errors.Is(err, errTooLarge) { + // Contract row 1: report the code, as for any other rejection. + return fail(&bundlesig.Error{Code: bundlesig.CodeSidecarTooLarge, Detail: err.Error()}) + } fmt.Fprintf(stderr, "verify: %v\n", err) return 1 } @@ -299,6 +303,10 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int { return 0 } +// errTooLarge marks a readBounded refusal for size, so verify can still print +// the contract code for an oversized sidecar. +var errTooLarge = errors.New("over the size limit") + // beforeOpenHook is a test-only seam called between readBounded's lstat and // its open, so a test can swap the path in that window. var beforeOpenHook func(path string) @@ -336,7 +344,7 @@ func readBounded(path string, limit int64) ([]byte, error) { return nil, err } if int64(len(data)) > limit { - return nil, fmt.Errorf("%s exceeds the %d-byte limit", path, limit) + return nil, fmt.Errorf("%s exceeds the %d-byte limit: %w", path, limit, errTooLarge) } return data, nil } diff --git a/cmd/tpa-db/main_test.go b/cmd/tpa-db/main_test.go index a874e4391..291e428da 100644 --- a/cmd/tpa-db/main_test.go +++ b/cmd/tpa-db/main_test.go @@ -264,6 +264,11 @@ func TestVerifyBoundsItsReads(t *testing.T) { if code != 1 || !strings.Contains(errs, "exceeds") { t.Fatalf("oversized sidecar: exit %d, stderr %q", code, errs) } + // The machine-readable contract code is still printed (row 1). + _, out, _ := runCLI(t, "verify", "--bundle", bundle, "--signer", pub) + if strings.TrimSpace(out) != bundlesig.CodeSidecarTooLarge { + t.Fatalf("oversized sidecar: stdout %q, want %q", out, bundlesig.CodeSidecarTooLarge) + } if err := os.Remove(bundle + ".sig"); err != nil { t.Fatal(err) From abc35d8ae0ccfae16689a3db47f31264a74c9699 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 16:04:55 +0300 Subject: [PATCH 25/35] test(tpa-db): skip the POSIX key-mode assertion on Windows Related #938 --- cmd/tpa-db/main_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/tpa-db/main_test.go b/cmd/tpa-db/main_test.go index 291e428da..c23623a50 100644 --- a/cmd/tpa-db/main_test.go +++ b/cmd/tpa-db/main_test.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" "testing" @@ -59,7 +60,9 @@ func TestKeygenFilesAndRefusesOverwrite(t *testing.T) { if err != nil { t.Fatal(err) } - if st.Mode().Perm() != 0o600 { + // Windows has no POSIX permission bits (os reports 0666 for any writable + // file); the 0600 guarantee is a POSIX one. + if runtime.GOOS != "windows" && st.Mode().Perm() != 0o600 { t.Fatalf("operator.key mode = %v, want 0600", st.Mode().Perm()) } pubFile, err := os.ReadFile(filepath.Join(dir, "operator.pub")) From 14e175dd22fabbb2fac1bed7b2c01eb09c52cf6c Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 16:06:31 +0300 Subject: [PATCH 26/35] fix(webui): Review keeps the signature bundle status fresh; authority-neutral epoch_ahead label Sol 6.1 Web UI review: Review read the shared cached overview once, so a bundle that turned degraded later stayed hidden; it now refreshes on entry and every 30 s while mounted (never for tenant sessions). epoch_ahead no longer tells an operator-root user to update the binary. Related #938 --- frontend/src/utils/signatureBundle.ts | 2 +- frontend/src/views/Review.vue | 16 +++++++++- .../unit/review-signature-bundle.spec.ts | 32 ++++++++++++++++++- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/frontend/src/utils/signatureBundle.ts b/frontend/src/utils/signatureBundle.ts index b833bd2fd..4c4225aa2 100644 --- a/frontend/src/utils/signatureBundle.ts +++ b/frontend/src/utils/signatureBundle.ts @@ -113,7 +113,7 @@ const REJECTION_LABELS: Record = { tamper: 'Tamper: signature did not verify', downgrade: 'Downgrade refused', stale_epoch_replay: 'Stale epoch replay refused', - epoch_ahead: 'Bundle newer than this binary trusts (update mcpproxy)', + epoch_ahead: 'Bundle epoch is newer than this install trusts (publisher bundle: update mcpproxy; operator bundle: check tpa_operator_epoch)', unsigned_refused_by_ratchet: 'Unsigned bundle refused after a signed one was used', signature_required: 'Signature required but missing', authority_not_active: 'Signed by the non-active authority', diff --git a/frontend/src/views/Review.vue b/frontend/src/views/Review.vue index c9d75eb9a..bdb38098f 100644 --- a/frontend/src/views/Review.vue +++ b/frontend/src/views/Review.vue @@ -40,9 +40,23 @@ import ScanHistory from '@/components/ScanHistory.vue' import ReviewQueueList from '@/components/ReviewQueueList.vue' import SignatureBundleStatus from '@/components/security/SignatureBundleStatus.vue' import { useSecurityScannerStatus } from '@/composables/useSecurityScannerStatus' +import { useAuthStore } from '@/stores/auth' import { isActiveBlocker, reviewQueueScopeText, sortReviewRows, summarizeReviewQueue, type ReviewSort } from '@/utils/reviewQueue' // Shared with Home/Security: one /security/overview fetch; renders nothing when unavailable (scoped session, older daemon). -const { signatureBundle } = useSecurityScannerStatus() +const { signatureBundle, refresh: refreshScannerStatus } = useSecurityScannerStatus() +// The signature bundle status is shared, cached state: re-read it on entry and +// every 30 s while Review is open, so a bundle that became degraded after the +// first load (a CLI drop and reload, a refused candidate) is never hidden +// behind a stale healthy status (Spec 101 FR-020). +const BUNDLE_REFRESH_MS = 30_000 +let bundleTimer: ReturnType | undefined +onMounted(() => { + // /security/overview is an admin-only door: a tenant session never polls it. + if (useAuthStore().principalKind === 'tenant') return + void refreshScannerStatus?.() + bundleTimer = setInterval(() => { void refreshScannerStatus?.() }, BUNDLE_REFRESH_MS) +}) +onUnmounted(() => { if (bundleTimer) clearInterval(bundleTimer) }) const route = useRoute(); const scopeQuery = useScopeQuery('review'); const queue = ref(null); const loading = ref(false); const error = ref('') const selectedServer = computed(() => typeof route.params.server === 'string' ? route.params.server : '') const change = computed(() => scopeQuery.state.change ?? '') diff --git a/frontend/tests/unit/review-signature-bundle.spec.ts b/frontend/tests/unit/review-signature-bundle.spec.ts index 94e7bc9e3..6dc7be59e 100644 --- a/frontend/tests/unit/review-signature-bundle.spec.ts +++ b/frontend/tests/unit/review-signature-bundle.spec.ts @@ -9,6 +9,7 @@ import { createRouter, createMemoryHistory } from 'vue-router' const FULL = 'c0ffee012345' + 'd'.repeat(52) const bundleRef = vi.hoisted(() => ({ current: null as unknown })) +const refreshSpy = vi.hoisted(() => vi.fn()) vi.mock('@/services/api', () => ({ default: { getReviewQueue: vi.fn(), listScanHistory: vi.fn() }, @@ -16,7 +17,7 @@ vi.mock('@/services/api', () => ({ vi.mock('@/composables/useSecurityScannerStatus', async () => { const { ref } = await import('vue') return { - useSecurityScannerStatus: () => ({ signatureBundle: ref(bundleRef.current) }), + useSecurityScannerStatus: () => ({ signatureBundle: ref(bundleRef.current), refresh: refreshSpy }), refreshSecurityScannerStatus: vi.fn(), } }) @@ -93,3 +94,32 @@ describe('Review page signature bundle (Spec 101 FR-020)', () => { expect(wrapper.find('[data-test="bundle-degraded-alert"]').exists()).toBe(false) }) }) + +// The bundle status is shared, cached state: Review must re-read it on entry +// and keep it fresh while mounted, or a bundle that became degraded after the +// first load (CLI drop + reload, a refused candidate) stays hidden behind the +// cached healthy status (Sol 6.1 review of the Web UI). +describe('Review page keeps the signature bundle status fresh', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + ;(api.getReviewQueue as any).mockResolvedValue({ success: true, data: { count: 0, servers: [] } }) + ;(api.listScanHistory as any).mockResolvedValue({ success: true, data: { scans: [], total: 0 } }) + bundleRef.current = healthy + }) + + it('refreshes on entry and periodically while mounted, and stops after unmount', async () => { + vi.useFakeTimers() + try { + const wrapper = await open() + expect(refreshSpy).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(30_000) + expect(refreshSpy).toHaveBeenCalledTimes(2) + wrapper.unmount() + await vi.advanceTimersByTimeAsync(60_000) + expect(refreshSpy).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) +}) From b9ee5c2e2f3c3004d252b516846b730e51bff57e Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 16:07:30 +0300 Subject: [PATCH 27/35] fix(ci): keep Spec 101 sidecar fixtures byte-exact on Windows checkout Related #938 --- .gitattributes | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitattributes b/.gitattributes index c3cd32e14..4b8f46902 100644 --- a/.gitattributes +++ b/.gitattributes @@ -43,3 +43,8 @@ internal/profile/testdata/contract/*.json text eol=lf internal/httpapi/testdata/sc006_activity_recordsets.json text eol=lf cmd/mcpproxy/testdata/cli108/* text eol=lf internal/server/testdata/profiles_v3/*.json text eol=lf + +# Spec 101 sidecar conformance fixtures are byte-exact (signatures cover exact +# bytes, the 4096/4097-byte ceilings are measured on the file, and one fixture +# deliberately ends in CRLF). Never convert line endings on checkout. +internal/security/bundlesig/testdata/* -text From 86eccfc171d09e8126cf9a64200aa8bc169b8337 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 16:08:26 +0300 Subject: [PATCH 28/35] fix(webui): tamper label covers key-id mismatch as well as a bad signature Related #938 --- frontend/src/utils/signatureBundle.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/utils/signatureBundle.ts b/frontend/src/utils/signatureBundle.ts index 4c4225aa2..74a3b172e 100644 --- a/frontend/src/utils/signatureBundle.ts +++ b/frontend/src/utils/signatureBundle.ts @@ -110,7 +110,7 @@ export function describeDegradedReason(reason: string | undefined): string { } const REJECTION_LABELS: Record = { - tamper: 'Tamper: signature did not verify', + tamper: 'Tamper: the signature or the signing-key identity check failed', downgrade: 'Downgrade refused', stale_epoch_replay: 'Stale epoch replay refused', epoch_ahead: 'Bundle epoch is newer than this install trusts (publisher bundle: update mcpproxy; operator bundle: check tpa_operator_epoch)', From 046ff03a511ee2b98c343eb86cb18a520b7cab55 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 16:09:56 +0300 Subject: [PATCH 29/35] docs(spec-101): tick US1 review and PR tasks Related #938 --- ROADMAP.md | 4 ++-- specs/101-tpa-db/tasks.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 70993dfb9..becb7a21c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | 28/30 (93%) | [101-tpa-db](./specs/101-tpa-db/) | | +| tpa-db: versioned TPA signature database for the offline scanner | In progress | P1 | 30/30 (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/) | `in-flight` | 28/30 (93%) | +| [101-tpa-db](./specs/101-tpa-db/) | `shipped` | 30/30 (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/specs/101-tpa-db/tasks.md b/specs/101-tpa-db/tasks.md index 65f2019ae..983d1de47 100644 --- a/specs/101-tpa-db/tasks.md +++ b/specs/101-tpa-db/tasks.md @@ -96,9 +96,9 @@ plan.md was written 2026-08-26 against Go 1.25.5. Re-validated against `main` @ - [x] T080 `./scripts/test-api-e2e.sh`; `go test -race ./internal/security/... ./internal/storage/... ./internal/config/... ./internal/telemetry/...`; the CLAUDE.md skip-regex race run over `internal/server`/`httpapi`; golangci-lint v2 bare and `--build-tags server`. - [x] T081 Review workflow (dimensions → 3 refuters per finding → fix confirmed). -- [ ] T082 Cross-model review (opencode Sol 6.1; ≤10 rounds). +- [x] T082 Cross-model review (opencode Sol 6.1; ≤10 rounds). - [x] T083 Docs: `docs/features/tpa-bundle-signing.md` (operator: verify, drop, reload, roll back, read status, air-gapped walkthrough, Windows/NFS residual), `docs/features/security-quarantine.md` + tool-scanner docs (degraded states), `docs/configuration.md` (new keys), quickstart.md corrections (Δ5, Δ8, Δ13, sequence ≥ baseline), `CLAUDE.md` Recent Changes, `roadmap.yaml` + `scripts/gen-roadmap.py`. -- [ ] T084 Open the PR; tick this file. +- [x] T084 Open the PR; tick this file. --- From 5d99de4b632256c87d0db7189b89a4f986661d7e Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 17:10:27 +0300 Subject: [PATCH 30/35] fix(security): confine rollback paths to the bundle directory; mtime from the descriptor CodeQL go/path-injection (2 high) traced the admin rollback request path to the candidate open, and a by-path os.Stat for the unsigned-drop mtime fallback. A rollback --path must now be an absolute file directly in the directory of security.tpa_bundle_path (usage error otherwise, refused before anything is opened), so the rollback door cannot probe arbitrary files; the unsigned freshness fallback takes the modification time from the descriptor the bytes were read through instead of re-stating the path. Related #938 --- cmd/mcpproxy/security_cmd.go | 2 +- docs/features/tpa-bundle-signing.md | 5 +- internal/security/scanner/candidate_open.go | 46 +++++++++----- .../security/scanner/tpa_bundle_rollback.go | 33 +++++++++- .../tpa_bundle_rollback_confine_test.go | 62 +++++++++++++++++++ .../scanner/tpa_bundle_rollback_unpin_test.go | 1 + .../security/scanner/tpa_bundle_runtime.go | 11 ++-- internal/security/scanner/tpa_candidate.go | 4 ++ specs/101-tpa-db/quickstart.md | 11 ++-- 9 files changed, 145 insertions(+), 30 deletions(-) create mode 100644 internal/security/scanner/tpa_bundle_rollback_confine_test.go diff --git a/cmd/mcpproxy/security_cmd.go b/cmd/mcpproxy/security_cmd.go index e11cb7458..6ffd191aa 100644 --- a/cmd/mcpproxy/security_cmd.go +++ b/cmd/mcpproxy/security_cmd.go @@ -2476,7 +2476,7 @@ Examples: }, } cmd.Flags().BoolVar(&embedded, "embedded", false, "Roll back to the bundle compiled into this binary") - cmd.Flags().StringVar(&path, "path", "", "Path to the target bundle file (a hint for where to find the bytes; a relative path is resolved against the current directory)") + cmd.Flags().StringVar(&path, "path", "", "Path to the target bundle file (a hint for where to find the bytes; a relative path is resolved against the current directory; it must sit directly in the directory of security.tpa_bundle_path)") cmd.Flags().StringVar(&digest, "digest", "", "Full SHA-256 of the target as 64 lowercase hex characters (required)") return cmd } diff --git a/docs/features/tpa-bundle-signing.md b/docs/features/tpa-bundle-signing.md index 4b8d9f2be..594b072cf 100644 --- a/docs/features/tpa-bundle-signing.md +++ b/docs/features/tpa-bundle-signing.md @@ -213,13 +213,16 @@ The history is how you learn a known-good digest without reading it from the fil Then roll back, naming exactly one target: ```bash -# to a bundle file (its sidecar is PATH.sig) +# to a bundle file (its sidecar is PATH.sig); it must sit directly in the +# directory of tpa_bundle_path, e.g. next to /var/lib/tpa/scanner-bundle.json mcpproxy security bundle rollback --path /var/lib/tpa/scanner-bundle.v1.json --digest <64-hex> # to the corpus compiled into the binary mcpproxy security bundle rollback --embedded --digest <64-hex of the embedded corpus> ``` +A `--path` outside the directory of `tpa_bundle_path` (another directory, a `..` escape, a relative path) is refused as a usage error before the file is opened. Rollback is an admin action, but it should not double as a way to learn whether, or with which digest, an arbitrary file exists on the host. Keep the releases you may roll back to next to the configured bundle. + The embedded corpus's digest is the `full_digest` shown by `mcpproxy security bundle` while the embedded corpus is serving. The same operation over REST is `POST /api/v1/security/bundle/rollback` with `{"embedded": bool, "path": string, "digest": string}`. It returns 400 for a usage error (both or neither of `embedded` and `path`, or a digest that is not 64 lowercase hex) and 409 with the rejection when the rollback is refused. diff --git a/internal/security/scanner/candidate_open.go b/internal/security/scanner/candidate_open.go index 616695c8b..a0c66826a 100644 --- a/internal/security/scanner/candidate_open.go +++ b/internal/security/scanner/candidate_open.go @@ -9,6 +9,7 @@ import ( "math" "os" "sync" + "time" ) // candidateReadError is the classified failure of readCandidateFile. Reason is @@ -61,8 +62,9 @@ var candidateParked = struct { // candidateResult is what the reader goroutine hands back. type candidateResult struct { - data []byte - err error + data []byte + modTime time.Time + err error } // readCandidateFile reads a TPA bundle candidate (or its sidecar) of at most @@ -88,14 +90,22 @@ type candidateResult struct { // closes it whenever its read finally returns. While such an abandoned reader // is parked the path is refused fast (see candidateParked). func readCandidateFile(ctx context.Context, path string, limit int64) ([]byte, error) { + data, _, err := readCandidateFileStat(ctx, path, limit) + return data, err +} + +// readCandidateFileStat is readCandidateFile that also returns the modification +// time of the descriptor the bytes were read from (never a second stat of the +// path, which could name a different file by then). +func readCandidateFileStat(ctx context.Context, path string, limit int64) ([]byte, time.Time, error) { if err := ctx.Err(); err != nil { - return nil, &candidateReadError{Reason: RejectReadTimeout, Detail: "read budget expired before open: " + err.Error(), Err: err} + return nil, time.Time{}, &candidateReadError{Reason: RejectReadTimeout, Detail: "read budget expired before open: " + err.Error(), Err: err} } candidateParked.mu.Lock() if _, parked := candidateParked.paths[path]; parked { candidateParked.mu.Unlock() - return nil, &candidateReadError{Reason: RejectReadTimeout, Detail: "previous read still blocked"} + return nil, time.Time{}, &candidateReadError{Reason: RejectReadTimeout, Detail: "previous read still blocked"} } candidateParked.mu.Unlock() @@ -110,7 +120,7 @@ func readCandidateFile(ctx context.Context, path string, limit int64) ([]byte, e ) resCh := make(chan candidateResult, 1) go func() { - data, err := loadCandidate(path, limit, hook, wrap) + data, modTime, err := loadCandidate(path, limit, hook, wrap) mu.Lock() done = true wasAbandoned := abandoned @@ -121,12 +131,12 @@ func readCandidateFile(ctx context.Context, path string, limit int64) ([]byte, e candidateParked.mu.Unlock() return } - resCh <- candidateResult{data: data, err: err} + resCh <- candidateResult{data: data, modTime: modTime, err: err} }() select { case r := <-resCh: - return r.data, r.err + return r.data, r.modTime, r.err case <-ctx.Done(): mu.Lock() if done { @@ -134,21 +144,21 @@ func readCandidateFile(ctx context.Context, path string, limit int64) ([]byte, e // in the channel (it does not send when abandoned, but it was not). mu.Unlock() r := <-resCh - return r.data, r.err + return r.data, r.modTime, r.err } abandoned = true candidateParked.mu.Lock() candidateParked.paths[path] = struct{}{} candidateParked.mu.Unlock() mu.Unlock() - return nil, &candidateReadError{Reason: RejectReadTimeout, Detail: fmt.Sprintf("read budget expired: %v", ctx.Err()), Err: ctx.Err()} + return nil, time.Time{}, &candidateReadError{Reason: RejectReadTimeout, Detail: fmt.Sprintf("read budget expired: %v", ctx.Err()), Err: ctx.Err()} } } // loadCandidate is the body of the reader goroutine: open, classify the // descriptor, size-check it, read it bounded. It owns and always closes the // descriptor. -func loadCandidate(path string, limit int64, hook func(string), wrap func(io.Reader) io.Reader) ([]byte, error) { +func loadCandidate(path string, limit int64, hook func(string), wrap func(io.Reader) io.Reader) ([]byte, time.Time, error) { if hook != nil { hook(path) } @@ -156,9 +166,9 @@ func loadCandidate(path string, limit int64, hook func(string), wrap func(io.Rea if err != nil { var ce *candidateReadError if errors.As(err, &ce) { - return nil, ce + return nil, time.Time{}, ce } - return nil, &candidateReadError{ + return nil, time.Time{}, &candidateReadError{ Reason: RejectReadError, Detail: "open: " + err.Error(), NotExist: errors.Is(err, fs.ErrNotExist), @@ -166,9 +176,13 @@ func loadCandidate(path string, limit int64, hook func(string), wrap func(io.Rea } } defer f.Close() + var modTime time.Time + if st, serr := f.Stat(); serr == nil { + modTime = st.ModTime() + } if size > limit { - return nil, &candidateReadError{Reason: RejectCeilingExceeded, Detail: fmt.Sprintf("limit %d bytes, observed %d bytes", limit, size)} + return nil, time.Time{}, &candidateReadError{Reason: RejectCeilingExceeded, Detail: fmt.Sprintf("limit %d bytes, observed %d bytes", limit, size)} } // Read at most limit+1 bytes from the SAME descriptor: one extra byte is @@ -185,12 +199,12 @@ func loadCandidate(path string, limit int64, hook func(string), wrap func(io.Rea } data, err := io.ReadAll(io.LimitReader(src, readMax)) if err != nil { - return nil, &candidateReadError{Reason: RejectReadError, Detail: "read: " + err.Error(), Err: err} + return nil, time.Time{}, &candidateReadError{Reason: RejectReadError, Detail: "read: " + err.Error(), Err: err} } if int64(len(data)) > limit { - return nil, &candidateReadError{Reason: RejectCeilingExceeded, Detail: fmt.Sprintf("limit %d bytes, observed more than %d bytes while reading", limit, limit)} + return nil, time.Time{}, &candidateReadError{Reason: RejectCeilingExceeded, Detail: fmt.Sprintf("limit %d bytes, observed more than %d bytes while reading", limit, limit)} } - return data, nil + return data, modTime, nil } // notRegularError builds the refusal for a candidate that is not a plain file. diff --git a/internal/security/scanner/tpa_bundle_rollback.go b/internal/security/scanner/tpa_bundle_rollback.go index 87426f493..2c6c98862 100644 --- a/internal/security/scanner/tpa_bundle_rollback.go +++ b/internal/security/scanner/tpa_bundle_rollback.go @@ -3,6 +3,8 @@ package scanner import ( "errors" "fmt" + "path/filepath" + "strings" "time" "go.uber.org/zap" @@ -53,6 +55,31 @@ func validateRollbackRequest(req BundleRollbackRequest) error { return nil } +// confineRollbackPath resolves a rollback target path from an admin request and +// requires it to sit inside the directory of the configured bundle +// (security.tpa_bundle_path), where an operator keeps the releases they may +// roll back to. Anything else is a usage error and is never opened: without the +// confinement the rollback door would let a caller probe the existence, type +// and digest of any file the proxy can read. +func confineRollbackPath(configured, requested string) (string, error) { + if configured == "" { + return "", fmt.Errorf("%w: rollback by path needs security.tpa_bundle_path; the target must live in that directory (or roll back to the embedded default)", ErrBundleRollbackUsage) + } + if !filepath.IsAbs(requested) { + return "", fmt.Errorf("%w: the rollback path must be absolute", ErrBundleRollbackUsage) + } + dirAbs, err := filepath.Abs(filepath.Dir(configured)) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrBundleRollbackUsage, err) + } + dir := filepath.Clean(dirAbs) + target := filepath.Clean(requested) + if !strings.HasPrefix(target, dir+string(filepath.Separator)) || filepath.Dir(target) != dir { + return "", fmt.Errorf("%w: the rollback target must be a file directly in the bundle directory %s", ErrBundleRollbackUsage, dir) + } + return target, nil +} + // RollbackBundle performs the explicit rollback of FR-010. On success the // target serves, is pinned, the release rolled away from is deny-listed, the // watermark is untouched and the override is logged at WARN. A refusal returns @@ -119,7 +146,11 @@ func (rt *bundleRuntime) rollbackLocked(req BundleRollbackRequest, log *zap.Logg target = embeddedRollbackTarget(policy, e.digest, seq, e.meta.GeneratedAt) sv = embeddedServing(e, policy) } else { - cand, rej := readFileCandidate(req.Path) + targetPath, err := confineRollbackPath(policy.Path, req.Path) + if err != nil { + return err + } + cand, rej := readFileCandidate(targetPath) if rej != nil { return refuse(rej, "") } diff --git a/internal/security/scanner/tpa_bundle_rollback_confine_test.go b/internal/security/scanner/tpa_bundle_rollback_confine_test.go new file mode 100644 index 000000000..e6e77c91f --- /dev/null +++ b/internal/security/scanner/tpa_bundle_rollback_confine_test.go @@ -0,0 +1,62 @@ +package scanner + +import ( + "errors" + "os" + "path/filepath" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" +) + +// A rollback names its target path in an admin request body. The path is +// confined to the directory of the configured bundle (security.tpa_bundle_path): +// anything else is a usage error, refused BEFORE the file is opened, so the +// rollback door cannot be used to probe the existence, type or digest of +// arbitrary files the proxy can read. +func TestRollback_PathIsConfinedToTheBundleDirectory(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + a, _, aPath := twoReleases(t, f) + + outsideDir := t.TempDir() + outside := filepath.Join(outsideDir, "previous-release.json") + f.putAt(outside, a) + + var opened atomic.Int32 + prev := candidateOpenHook + candidateOpenHook = func(p string) { + if filepath.Dir(p) == outsideDir || filepath.Base(p) == "escape.json" { + opened.Add(1) + } + } + t.Cleanup(func() { candidateOpenHook = prev }) + + for name, p := range map[string]string{ + "other directory": outside, + "dot-dot escape": filepath.Join(f.drop, "..", filepath.Base(outsideDir), "previous-release.json"), + "relative path": "previous-release.json", + "the directory": f.drop, + "sibling prefix": f.drop + "-x/escape.json", + } { + _, err := RollbackBundle(BundleRollbackRequest{Path: p, Digest: digestOf(a.Bundle)}, f.log) + require.Error(t, err, name) + require.True(t, errors.Is(err, ErrBundleRollbackUsage), "%s: want a usage error, got %v", name, err) + } + require.Zero(t, opened.Load(), "a path outside the bundle directory must never be opened") + + // Inside the directory still works. + _, err := RollbackBundle(BundleRollbackRequest{Path: aPath, Digest: digestOf(a.Bundle)}, f.log) + require.NoError(t, err) + _ = os.Remove(outside) +} + +// With no bundle path configured there is no directory to confine to, so a +// rollback by path is refused; the embedded default stays reachable. +func TestRollback_PathNeedsAConfiguredBundlePath(t *testing.T) { + f := newRTFixture(t, rtOpts{}) + f.sec.TPABundlePath = "" + ConfigureBundleFromConfig(f.sec, f.log) + _, err := RollbackBundle(BundleRollbackRequest{Path: filepath.Join(f.drop, "x.json"), Digest: digestOf([]byte("x"))}, f.log) + require.True(t, errors.Is(err, ErrBundleRollbackUsage), "got %v", err) +} diff --git a/internal/security/scanner/tpa_bundle_rollback_unpin_test.go b/internal/security/scanner/tpa_bundle_rollback_unpin_test.go index f370391ae..7ca6d0304 100644 --- a/internal/security/scanner/tpa_bundle_rollback_unpin_test.go +++ b/internal/security/scanner/tpa_bundle_rollback_unpin_test.go @@ -43,6 +43,7 @@ func TestRollback_ToAnUnactivatedSignedTargetArmsTheRatchet(t *testing.T) { target := signedCandidate(t, keyPublisher, bundleOpts{Seq: 7, Tag: "never-activated"}) targetPath := f.drop + "/target.json" f.putAt(targetPath, target) + f.run() // the server always configures the bundle policy before any rollback info, err := RollbackBundle(BundleRollbackRequest{Path: targetPath, Digest: digestOf(target.Bundle)}, f.log) require.NoError(t, err) diff --git a/internal/security/scanner/tpa_bundle_runtime.go b/internal/security/scanner/tpa_bundle_runtime.go index 226fb70aa..28aff61bd 100644 --- a/internal/security/scanner/tpa_bundle_runtime.go +++ b/internal/security/scanner/tpa_bundle_runtime.go @@ -5,7 +5,6 @@ import ( "context" "errors" "fmt" - "os" "strconv" "sync" "time" @@ -846,10 +845,8 @@ func newExternalServing(cand Candidate, acc *acceptedBundle) *servingBundle { // the next-best freshness signal for an unsigned drop and is strictly // better than nothing. A signed bundle's freshness comes from its // signed manifest only. - if !acc.Verified { - if st, err := os.Stat(cand.Path); err == nil { - sv.generatedAt = st.ModTime().UTC().Format(time.RFC3339) - } + if !acc.Verified && !cand.ModTime.IsZero() { + sv.generatedAt = cand.ModTime.UTC().Format(time.RFC3339) } } return sv @@ -881,7 +878,7 @@ func readFileCandidate(path string) (*Candidate, *BundleRejection) { return nil, readRejection(serr, "sidecar") } } - data, berr := readCandidateFile(ctx, path, MaxBundleBytes) + data, modTime, berr := readCandidateFileStat(ctx, path, MaxBundleBytes) if berr != nil { if present { // FR-008 stage order: the sidecar's grammar outranks the bundle's byte @@ -894,7 +891,7 @@ func readFileCandidate(path string) (*Candidate, *BundleRejection) { } return nil, readRejection(berr, "bundle") } - return &Candidate{Bundle: data, Sidecar: sidecar, SidecarPresent: present, Source: BundleSourceFile, Path: path}, nil + return &Candidate{Bundle: data, Sidecar: sidecar, SidecarPresent: present, Source: BundleSourceFile, Path: path, ModTime: modTime}, nil } // readRejection turns a candidate read failure into a recorded rejection. diff --git a/internal/security/scanner/tpa_candidate.go b/internal/security/scanner/tpa_candidate.go index caf5a15a8..d7bf45650 100644 --- a/internal/security/scanner/tpa_candidate.go +++ b/internal/security/scanner/tpa_candidate.go @@ -26,6 +26,10 @@ type Candidate struct { // Source is the BundleSource* label; Path is informational only. Source string Path string + // ModTime is the modification time of the descriptor the bundle was read + // from (zero when unknown); the freshness fallback for an unsigned drop + // without generated_at. Never re-read from the path. + ModTime time.Time } // evalMode says how the anti-downgrade rules treat a candidate. diff --git a/specs/101-tpa-db/quickstart.md b/specs/101-tpa-db/quickstart.md index 77de22372..38e544e10 100644 --- a/specs/101-tpa-db/quickstart.md +++ b/specs/101-tpa-db/quickstart.md @@ -136,16 +136,19 @@ Read the history to learn the digest you want: The history only lists releases that activated, so it will not contain the sequence-1 file. Take its digest from the file you hold, which is fine here because you made it; in an incident, take it from the history or from your release record, never from the file under suspicion: ```bash -V1=$(shasum -a 256 "$SCRATCH/v1/scanner-bundle.json" | cut -d' ' -f1) -./mcpproxy security bundle rollback --path "$SCRATCH/v1/scanner-bundle.json" --digest "$V1" \ +# A rollback target must sit directly in the directory of tpa_bundle_path. +cp "$SCRATCH/v1/scanner-bundle.json" "$SCRATCH/scanner-bundle.v1.json" +cp "$SCRATCH/v1/scanner-bundle.json.sig" "$SCRATCH/scanner-bundle.v1.json.sig" +V1=$(shasum -a 256 "$SCRATCH/scanner-bundle.v1.json" | cut -d' ' -f1) +./mcpproxy security bundle rollback --path "$SCRATCH/scanner-bundle.v1.json" --digest "$V1" \ --config "$SCRATCH/config.json" --data-dir "$SCRATCH/data" # or, to go back to the corpus compiled into the binary: rollback --embedded --digest ``` -Give **exactly one** of `--embedded` or `--path`. The path is a *hint about where to find those bytes*; the digest is the authorization. Rollback is the one path that deliberately bypasses the watermark, so identifying its target by an attacker-writable path would hand the attacker the choice of what the bypass lands on. A target below your operator baseline is refused. Supply the wrong digest and it is refused `rollback_target_mismatch` — try it, it is one command: +Give **exactly one** of `--embedded` or `--path`. The path must be a file directly in the directory of `tpa_bundle_path` (anything else is refused before it is opened, so the rollback door cannot probe other files). The path is a *hint about where to find those bytes*; the digest is the authorization. Rollback is the one path that deliberately bypasses the watermark, so identifying its target by an attacker-writable path would hand the attacker the choice of what the bypass lands on. A target below your operator baseline is refused. Supply the wrong digest and it is refused `rollback_target_mismatch` — try it, it is one command: ```bash -./mcpproxy security bundle rollback --path "$SCRATCH/v1/scanner-bundle.json" \ +./mcpproxy security bundle rollback --path "$SCRATCH/scanner-bundle.v1.json" \ --digest 0000000000000000000000000000000000000000000000000000000000000000 … # refused (409) ``` From dea461878e53c5b82e541a1185dce97c579a997c Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 17:16:12 +0300 Subject: [PATCH 31/35] fix(security): rollback confinement accepts a bundle directory at the filesystem root Sol 6.1 final round (P2): with tpa_bundle_path directly under / (or a drive root) the prefix check required a doubled separator and refused every sibling target. Related #938 --- internal/security/scanner/tpa_bundle_rollback.go | 6 +++++- .../scanner/tpa_bundle_rollback_confine_test.go | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/internal/security/scanner/tpa_bundle_rollback.go b/internal/security/scanner/tpa_bundle_rollback.go index 2c6c98862..c27a8be90 100644 --- a/internal/security/scanner/tpa_bundle_rollback.go +++ b/internal/security/scanner/tpa_bundle_rollback.go @@ -74,7 +74,11 @@ func confineRollbackPath(configured, requested string) (string, error) { } dir := filepath.Clean(dirAbs) target := filepath.Clean(requested) - if !strings.HasPrefix(target, dir+string(filepath.Separator)) || filepath.Dir(target) != dir { + prefix := dir + if !strings.HasSuffix(prefix, string(filepath.Separator)) { + prefix += string(filepath.Separator) // "/" and "C:\" already end in one + } + if !strings.HasPrefix(target, prefix) || filepath.Dir(target) != dir { return "", fmt.Errorf("%w: the rollback target must be a file directly in the bundle directory %s", ErrBundleRollbackUsage, dir) } return target, nil diff --git a/internal/security/scanner/tpa_bundle_rollback_confine_test.go b/internal/security/scanner/tpa_bundle_rollback_confine_test.go index e6e77c91f..689aa63dc 100644 --- a/internal/security/scanner/tpa_bundle_rollback_confine_test.go +++ b/internal/security/scanner/tpa_bundle_rollback_confine_test.go @@ -60,3 +60,18 @@ func TestRollback_PathNeedsAConfiguredBundlePath(t *testing.T) { _, err := RollbackBundle(BundleRollbackRequest{Path: filepath.Join(f.drop, "x.json"), Digest: digestOf([]byte("x"))}, f.log) require.True(t, errors.Is(err, ErrBundleRollbackUsage), "got %v", err) } + +// A bundle configured directly under the filesystem root still allows its +// siblings as rollback targets (the directory already ends in a separator). +func TestConfineRollbackPath_RootDirectory(t *testing.T) { + root := string(filepath.Separator) + if v := filepath.VolumeName(os.TempDir()); v != "" { + root = v + string(filepath.Separator) + } + got, err := confineRollbackPath(filepath.Join(root, "scanner-bundle.json"), filepath.Join(root, "scanner-bundle.v1.json")) + require.NoError(t, err) + require.Equal(t, filepath.Join(root, "scanner-bundle.v1.json"), got) + + _, err = confineRollbackPath(filepath.Join(root, "scanner-bundle.json"), filepath.Join(root, "sub", "x.json")) + require.True(t, errors.Is(err, ErrBundleRollbackUsage), "a subdirectory is not the bundle directory") +} From 2000c3c4f699db6dd46980a429eaf3c0b920b695 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 17:17:49 +0300 Subject: [PATCH 32/35] docs(roadmap): tpa-db-format done (#1701) Related #938 --- ROADMAP.md | 8 ++++---- roadmap.yaml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index becb7a21c..114988f1c 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) in review 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; US3 refresh waits on Spec 087. Spec: [101-tpa-db](./specs/101-tpa-db/) @@ -236,15 +236,15 @@ graph LR tpa_db_format --> tpa_db_corpus tpa_db_format --> tpa_db_refresh - classDef in_review fill:#9a6700,stroke:#5c3d00,color:#ffffff; + classDef done fill:#1f7a1f,stroke:#0d3d0d,color:#ffffff; classDef todo fill:#6e7781,stroke:#3d4248,color:#ffffff; - class tpa_db_format in_review; + class tpa_db_format done; class tpa_db_corpus,tpa_db_refresh todo; ``` | Task | Status | Refs | | --- | --- | --- | -| Signature DB format + loader (versioned, signed, bundled default) | 🟡 In review | #1701 | +| 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 | — | diff --git a/roadmap.yaml b/roadmap.yaml index 9cd49307c..6bd22337b 100644 --- a/roadmap.yaml +++ b/roadmap.yaml @@ -345,11 +345,11 @@ epics: priority: P1 spec: specs/101-tpa-db depends_on: [scanner-simplification] - note: "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) in review as #1701; US2 corpus in a sibling session; US3 refresh waits on Spec 087." + note: "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." tasks: - id: tpa-db-format title: "Signature DB format + loader (versioned, signed, bundled default)" - status: in_review + status: done pr: "#1701" depends_on: [] - id: tpa-db-corpus From 172dcf4535206c08c39598f94cb0d3339ec784ba Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 19:23:24 +0300 Subject: [PATCH 33/35] test(scanner): ENOTDIR-vs-absent candidate test is POSIX-only Windows reports a path under a regular file as ERROR_PATH_NOT_FOUND. Related #938 --- internal/security/scanner/candidate_open_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/security/scanner/candidate_open_test.go b/internal/security/scanner/candidate_open_test.go index 4fcf9a075..2637fca96 100644 --- a/internal/security/scanner/candidate_open_test.go +++ b/internal/security/scanner/candidate_open_test.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "runtime" "sync/atomic" "testing" "time" @@ -161,6 +162,12 @@ func TestReadCandidateFile_DirectoryRefused(t *testing.T) { func TestReadCandidateFile_NotExistOnlyForENOENT(t *testing.T) { // A path whose parent is a regular file fails with ENOTDIR, which is a // read failure, not "absent": the loader must not treat it as unsigned. + // Windows reports that path as ERROR_PATH_NOT_FOUND (genuinely absent), so + // the distinction is a POSIX one. It cannot matter there either: a sidecar + // lives in its bundle's directory, so that bundle read would fail too. + if runtime.GOOS == "windows" { + t.Skip("POSIX ENOTDIR semantics") + } f := writeCandidate(t, "file", []byte("x")) _, err := readCandidateFile(context.Background(), filepath.Join(f, "child"), 16) ce := requireCandidateErr(t, err, RejectReadError) From 560a97a3a9b43aad687bc7beb1c3beb9135354de Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 20:52:04 +0300 Subject: [PATCH 34/35] docs(roadmap): regenerate ROADMAP.md after merging main Related #938 --- ROADMAP.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ROADMAP.md b/ROADMAP.md index 114988f1c..e7f3a7185 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1174,3 +1174,4 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—` | [115-mcp-credential-lifecycle](./specs/115-mcp-credential-lifecycle/) | `shipped` | 86/87 (99%) | | [116-config-commit-consistency](./specs/116-config-commit-consistency/) | `in-flight` | 81/124 (65%) | | [117-review-bound-approvals](./specs/117-review-bound-approvals/) | `in-flight` | 46/50 (92%) | +| [118-auto-rescan-on-change](./specs/118-auto-rescan-on-change/) | — | — | From 74074b2bd0098bc25d6c0e60b5ddb3ecb75f37ad Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 20 Oct 2026 21:53:47 +0300 Subject: [PATCH 35/35] docs(roadmap): regenerate ROADMAP.md after merging main Related #938 --- ROADMAP.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ROADMAP.md b/ROADMAP.md index e7f3a7185..bc60f6a8c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1175,3 +1175,4 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—` | [116-config-commit-consistency](./specs/116-config-commit-consistency/) | `in-flight` | 81/124 (65%) | | [117-review-bound-approvals](./specs/117-review-bound-approvals/) | `in-flight` | 46/50 (92%) | | [118-auto-rescan-on-change](./specs/118-auto-rescan-on-change/) | — | — | +| [118-pin-visibility-boundary](./specs/118-pin-visibility-boundary/) | — | — |