Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5007a11
spec(120): structural change gate in front of the scan-mode approval
Dumbris Oct 25, 2026
e9d030b
feat(quarantine): structural change gate in front of the scan-mode ap…
Dumbris Oct 25, 2026
e3ba61c
test(runtime): marker-fault fixture asserts the retitled annotation (…
Dumbris Oct 25, 2026
9a2ebd3
docs(roadmap): list spec 120
Dumbris Oct 25, 2026
08688e0
fix(quarantine): review round 1 — exact numbers, schema-aware text st…
Dumbris Oct 25, 2026
133d8e2
docs(oas): regenerate for the auto_rescan_on_change wording (spec 120)
Dumbris Oct 25, 2026
850036a
fix(quarantine): review round 2 — additionalProperties recursion, tup…
Dumbris Oct 25, 2026
1e7f538
fix(quarantine): review round 3 — additionalProperties introduced wit…
Dumbris Oct 25, 2026
a046118
fix(quarantine): review round 4 — injective canonical value compariso…
Dumbris Oct 25, 2026
594f0eb
fix(quarantine): review round 5 — empty required/properties constrain…
Dumbris Oct 25, 2026
307fc40
fix(quarantine): review round 6 — combinators compare retained branch…
Dumbris Oct 25, 2026
a424a6e
fix(quarantine): review round 7 — const/enum always constrain, semant…
Dumbris Oct 25, 2026
df93544
fix(quarantine): review round 8 — new allOf branches checked for stee…
Dumbris Oct 25, 2026
fb03e9b
fix(quarantine): review round 9 — new allOf branches are checked for …
Dumbris Oct 25, 2026
5b268c5
fix(quarantine): review round 10 — new subschemas are checked for req…
Dumbris Oct 25, 2026
3ae97de
fix(quarantine): no arithmetic in the canonical-list allocation (Code…
Dumbris Oct 26, 2026
8b2e863
Merge remote-tracking branch 'origin/main' into 120-scan-structural-gate
Dumbris Oct 26, 2026
4296ad8
docs(oas): regenerate after merging main (spec 120)
Dumbris Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,27 @@ Releases follow [Semantic Versioning](https://semver.org/).
- **trust mode / tool approvals (Spec 119):** `scan` is now the default per-server trust mode, and under
`scan` a **changed** tool contract whose offline TPA scan is clean is approved automatically in the same
discovery pass (reason `scan_approved`, activity event `tool_auto_approved`) instead of being held. A
finding, a tool the scan could not cover, or a change that re-labels the tool as safer (`readOnlyHint`
false→true, `destructiveHint` true→false; held with signal `GATE-HINT-SAFER-FLIP`) is still held with the
evidence attached. A server whose `trust_mode` is unset now runs under `scan` from its next discovery
pass, so clean-scanned holds it accumulated are released without a click. The 2026-10-11 rule that a clean
verdict never approves a changed contract now applies to `manual` only. **Migration:** set
`trust_mode: "manual"` on any server whose tool changes you want a person to review; nothing changes for
servers with an explicit `trust_mode` or `auto_approve_tool_changes`. New servers added with no trust mode
are quarantined, scanned and released automatically on a clean verdict, as `scan` always did.
finding or a tool the scan could not cover is still held with the evidence attached. **Structural
change gate (Spec 120, same release):** before the scan verdict counts, a deterministic structural
comparison with the approved contract holds, whatever the verdict, any safety-hint change in either
direction (`GATE-HINT-CHANGE`; the two safer flips also `GATE-HINT-SAFER-FLIP`), a widened input schema
(`GATE-SCHEMA-SHAPE:<reason>:<path>`: a new required parameter, a type change, an enum member added or an
enum dropped, a loosened constraint, a default/const added or changed, a new parameter carrying a
default/const/enum), an output-schema change (`GATE-OUTPUT-SCHEMA`), and a change that moves the tool's
`call_tool` variant while its hash stays equal (explicit default hints dropped). Narrowing edits pass. No
word lists, thresholds or classifier. The `tool_auto_approved` event now carries the replaced contract
(`old_hash`, `old_description`, `old_schema`, `old_output_schema`, `old_annotations`), `approved_by`,
`gate_signals`, `scan_verdict` and `scan_bundle`. A server whose `trust_mode` is unset now runs under
`scan` from its next discovery pass, so clean-scanned holds it accumulated are released without a click:
the first start after the upgrade logs one WARN naming such servers, and `mcpproxy doctor` names them on
every run. The 2026-10-11 rule that a clean verdict never approves a changed contract now applies to
`manual` only. Not caught by any of this: a paraphrase, a one-word edit, slow text drift, behaviour that
changes behind an unchanged definition. **Migration:** set `trust_mode: "manual"` on any server whose
tool changes you want a person to review; nothing changes for servers with an explicit `trust_mode` or
`auto_approve_tool_changes`. A record whose stored annotation snapshot differs from the live hints in
dispatch tier only (a server last seen before hints were captured) is held once for review. New servers
added with no trust mode are quarantined, scanned and released automatically on a clean verdict, as
`scan` always did.

- **connect / disconnect:** `mcpproxy disconnect`, `DELETE /api/v1/connect/{client}`, and the Web UI
and tray Disconnect now also revoke the client's credential (`client-<id>`), so a secret copied out
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- 120-scan-structural-gate: Go 1.26, Vue 3 Web UI — a deterministic structural change gate (`internal/runtime/tool_contract_delta.go`, `contractDelta(approved, live) []Signal`) runs in front of the spec-119 scan-mode approval at both `checkToolApprovals` gate sites: any effective safety-hint change (`GATE-HINT-CHANGE`, safer flips also `GATE-HINT-SAFER-FLIP`), a recursive input-schema widening (`GATE-SCHEMA-SHAPE:<reason>:<path>`: new required, type, enum added/dropped, pattern/format, loosened bound, additionalProperties relaxed, default/const, new steering property, combinators, unknown approved schema), an output-schema change (`GATE-OUTPUT-SCHEMA`) and a hash-equal dispatch-tier change (`DeriveCallWith` moved, FR-009: closes the hash-match/formula/content shortcuts, `previousHintsProveRevert` requires the variant too) hold the change whatever the verdict; signals go first under the `MaxToolHeldSignals` cap. `tool_auto_approved` for a changed contract carries the before-state (`emitToolAutoApprovedChange`). One-time startup WARN (`warnScanDefaultOnce`, marker `scan_default_notice_done`) and `config.ScanDefaultTrustFinding` name servers with no `trust_mode`. Terminology family `hold_signal` (`contracts.AllHoldSignals`, `HoldSignal*` TS consts, `HOLD_SIGNAL_LABELS`), shared fixture `internal/runtime/testdata/contract_delta_cases.json` (Go exact signals + vitest review-line parity), qapilot `Contract.Shape`/`BenignWide`/QP-20/S28/S29. Docs: `docs/features/security-quarantine.md` "Structural changes are held". **No new dependencies.**
- 101-tpa-db US3 + 087-tpa-daily-refresh (tpa-bundle-refresh): Go 1.26, Vue 3 Web UI — a refresh cycle (`internal/security/scanner/tpa_refresh.go`: file leg then optional fetch leg, jittered `tpa_refresh_interval` default 24h, single-flight joins, `Nudge` on `config.reloaded`, joined in `Runtime.Close`) feeds the same source-neutral `evaluateCandidate` pipeline; the opt-in fetch (`tpa_fetch_enabled` default off, `tpa_fetch_url` no compiled-in default, env `MCPPROXY_TPA_FETCH_ENABLED`; Releases-API `latest` or direct `<url>` + `<url>.sig`, signed-only via `evalEnv.MustSign`, https/loopback-only transport with dial-time egress checks, host-only logs, outcomes kept apart from `last_rejection`) works today only with operator-signed bundles (publisher key set still empty); the activation self-check (`tpa_selfcheck.go`, parity-tested port of the `cmd/scan-eval` gate, embedded `bundled/selfcheck-corpus.json`) refuses a candidate as `gate_failure`; fetched bundles survive reload/restart (`externalConfigured`) and disabling fetch keeps them serving; activation observers drive the cached-only re-protection sweep (`internal/server/scan_bundle_reprotect.go`, held-evidence leg + `Service.StartScanFromCachedContracts` gated by `auto_baseline_scan`, marker `storage.BundleReprotectState`, RT-02: never approves, unquarantines or revokes); `BundleInfo` gains `last_checked_at`, `last_activated_at`, `stale`, `refresh`, `reprotect`; surfaces `POST /api/v1/security/bundle/refresh`, `mcpproxy security bundle refresh`, `BundleDoctorWarnings`, telemetry schema 16 refresh counters. Docs: `docs/features/tpa-bundle-signing.md` "Daily refresh and optional fetch", `docs/features/security-quarantine.md` "Re-protection after a bundle activates". **No new dependencies.**
- 101-tpa-db US1 (tpa-db-format): Go 1.26, Vue 3 Web UI — external TPA bundles are signed artifacts: `<bundle>.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 `<datadir>/tpa-lkg/`, full-digest rollback, `coverageOK` + async scan-mode admission degrade on the four FR-007/009a/009b conditions. Config: `require_signed_bundle` (default off), `tpa_active_root`, `tpa_operator_signers`, `tpa_operator_baseline`, `tpa_operator_epoch`. Surfaces: `GET/POST /api/v1/security/bundle{,/history,/rollback,/unpin,/reload}`, `mcpproxy security bundle …`, `cmd/tpa-db keygen|sign|verify`, telemetry schema 15 bundle keys. Publisher key set ships EMPTY. Docs: `docs/features/tpa-bundle-signing.md`. **No new dependencies.**
- 118-auto-rescan-on-change: Go 1.26, small Web UI change — after a tool definition changes (or a tool is added after the baseline) in `manual`/`scan` trust mode the in-process TPA scan of its current definition is recorded as hold evidence (`held_reason: awaiting_review` + `held_verdict: clean` for a clean verdict; findings/coverage as before) and a debounced informational server rescan (`scan_informational.go`, fed by `Runtime.AddToolDefinitionsChangedObserver`) brings the review scan line back to "current". Switch: `security.auto_rescan_on_change` (default on, env `MCPPROXY_AUTO_RESCAN_ON_CHANGE`) AND `auto_baseline_scan`. RT-02 untouched: a clean verdict never approves a change. Docs: `docs/features/security-quarantine.md` "Automatic informational baseline scan". **No new dependencies.**
Expand Down
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1177,3 +1177,4 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—`
| [118-auto-rescan-on-change](./specs/118-auto-rescan-on-change/) | — | — |
| [118-pin-visibility-boundary](./specs/118-pin-visibility-boundary/) | — | — |
| [119-scan-default-trust](./specs/119-scan-default-trust/) | — | — |
| [120-scan-structural-gate](./specs/120-scan-structural-gate/) | — | — |
10 changes: 5 additions & 5 deletions cmd/generate-types/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,7 @@ export type Tier =
sb.WriteString(enumBlock("ToolApproval", "ToolApprovalState", "Tool review states (approval_status)", contracts.AllToolApprovalStates()))
sb.WriteString(enumBlock("ActivityView", "ActivityView", "Activity views (the `view` URL parameter; the CLI has no `sessions`)", contracts.AllActivityViews()))
sb.WriteString(enumBlock("ClientPresence", "ClientPresenceState", "Client presence states (GET /clients row state)", contracts.AllClientPresenceStates()))
sb.WriteString(enumBlock("HoldSignal", "HoldSignal", "Structural-gate hold signal ids (held_signals prefixes, spec 120)", contracts.AllHoldSignals()))

// Tool types
sb.WriteString(`export interface Tool {
Expand Down Expand Up @@ -1142,11 +1143,10 @@ func enumBlock(prefix, typeName, comment string, values []string) string {
names := make([]string, 0, len(values))
for _, v := range values {
name := prefix
for _, w := range strings.Split(v, "_") {
if w == "" {
continue // a leading, trailing or repeated underscore adds no word
}
name += strings.ToUpper(w[:1]) + w[1:]
for _, w := range strings.FieldsFunc(v, func(r rune) bool { return r == '_' || r == '-' }) {
// snake_case and KEBAB-CASE ids (the GATE-* hold signals) both
// become one CamelCase word per segment.
name += strings.ToUpper(w[:1]) + strings.ToLower(w[1:])
}
names = append(names, name)
sb.WriteString("export const " + name + " = '" + v + "' as const;\n")
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1003,7 +1003,7 @@ block** — off by default, best-effort, and unable to change the baseline verdi
| `tpa_fetch_url` | string | `""` | Where the fetch step looks. **There is no working default URL**: empty means the fetch makes no request (`disabled`, reason `no_url`), and no URL is compiled into the binary until a release ships the first publisher key. Two shapes are recognised from the URL path: a GitHub Releases `…/repos/<owner>/<repo>/releases/latest` API URL (the release must carry one `scanner-bundle-<sequence>.json` asset with a sibling `.sig`), or any other path, fetched as the bundle with `<url>.sig` as its signature. Must be `https` (plain `http` only for `localhost` or a loopback address) and must not contain credentials, a query string or a fragment; the URL is never logged beyond its host. Rejected on write when `tpa_fetch_enabled` is on and the URL is empty. |
| `tpa_stale_after_days` | integer | absent (`30`) | Age in days of the active bundle (from its signed `generated_at`) after which it is flagged stale in `GET /api/v1/security/bundle`, `mcpproxy security bundle status` and `mcpproxy doctor`. An explicit `0` disables the stale flag and the doctor warning, and is different from absent. Negative values are rejected on write. |
| `auto_baseline_scan` | boolean | `true` | Kill switch for the **automatic informational baseline scan**. When on (the default), every newly added server gets one free in-process Pass-1 TPA scan, and once per installation a background sweep scans pre-existing enabled servers that have never been scanned (marker persisted in BBolt, so it runs exactly once and never delays startup). The result only populates the security badge / scan summary: it **never** quarantines, approves, or otherwise gates a server. Disabled servers are skipped, and so are `trust_mode: "scan"` servers — that mode's own admission gate scans them and auto-approves on a clean verdict, so the informational path stays out of it entirely rather than risk feeding that gate; this flag does not affect that separate path. Set to `false` to suppress all automatic scans; manual scans keep working. Hot-reloadable — the flag is read live at each decision point. Env override: `MCPPROXY_AUTO_BASELINE_SCAN` (`true`/`1`/`false`/`0`), which wins over this field on every path. |
| `auto_rescan_on_change` | boolean | `true` | Finer switch for the **automatic rescan after a tool definition changes** (Spec 118), in every trust mode except `auto`. When on, a changed tool, a tool added after the baseline, or a changed definition that moves again gets the in-process TPA scan of its current definition recorded as hold evidence (`held_reason`/`held_verdict`/`held_signals`, so the review screen shows a per-tool verdict without a click), and a debounced (about 5 s, coalesced per server) informational rescan of the server brings the review screen's scan line back to "current". Evidence and freshness only: this switch controls the `manual`-mode evidence scan and the server rescan, not the approval decision, which belongs to the trust-mode gate (under `scan`, the default, the gate's own synchronous scan approves a clean change; under `manual` nothing is approved automatically). It only takes effect while `auto_baseline_scan` is also on, so set this to `false` to keep the admission scan and turn off per-change rescans. The `trust_mode: "scan"` gate's own admission-time scan of new tools is not affected. Hot-reloadable. Env override: `MCPPROXY_AUTO_RESCAN_ON_CHANGE` (`true`/`1`/`false`/`0`), which wins over this field on every path. |
| `auto_rescan_on_change` | boolean | `true` | Finer switch for the **automatic rescan after a tool definition changes** (Spec 118), in every trust mode except `auto`. When on, a changed tool, a tool added after the baseline, or a changed definition that moves again gets the in-process TPA scan of its current definition recorded as hold evidence (`held_reason`/`held_verdict`/`held_signals`, so the review screen shows a per-tool verdict without a click), and a debounced (about 5 s, coalesced per server) informational rescan of the server brings the review screen's scan line back to "current". Evidence and freshness only: this switch controls the `manual`-mode evidence scan and the server rescan, not the approval decision, which belongs to the trust-mode gate (under `scan`, the default, the gate's own synchronous scan approves a clean change that passes the spec 120 structural gate — no safety-hint change, no widened input schema, no output-schema change; under `manual` nothing is approved automatically). It only takes effect while `auto_baseline_scan` is also on, so set this to `false` to keep the admission scan and turn off per-change rescans. The `trust_mode: "scan"` gate's own admission-time scan of new tools is not affected. Hot-reloadable. Env override: `MCPPROXY_AUTO_RESCAN_ON_CHANGE` (`true`/`1`/`false`/`0`), which wins over this field on every path. |
| `deep_scan.enabled` | boolean | `false` | Master opt-in for the heavy layer. When `false`, no Docker scanner runs and no source extraction is attempted — only the in-process baseline scanner executes. |
| `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). |
Expand Down
Loading
Loading