Skip to content

feat(security): Spec 101 US1 signed TPA bundle format and downgrade-proof loader - #1701

Merged
Dumbris merged 51 commits into
mainfrom
claude/101-tpa-db-format-8c7227
Oct 20, 2026
Merged

Dumbris merged 51 commits into
mainfrom
claude/101-tpa-db-format-8c7227

Conversation

@Dumbris

@Dumbris Dumbris commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Related #938

Spec 101 (tpa-db), User Story 1: an external TPA signature bundle becomes a signed, sequence-versioned artifact, and the loader verifies it before parsing it and refuses downgrades. US2 (seed corpus, signatures[], scan-eval --bundle) is in a sibling session. US3 (publication channel, Spec 087 fetch) comes later.

What changes for an operator

  • A bundle can ship with a detached <bundle>.sig: a one-line JSON object with exactly four keys and an Ed25519 signature over the exact bundle bytes (specs/101-tpa-db/contracts/sidecar-format.md).

  • New security keys:

    • require_signed_bundle (default off, maintainer decision D2)
    • tpa_active_root (publisher | operator)
    • tpa_operator_signers (base64 Ed25519 public keys)
    • tpa_operator_baseline
    • tpa_operator_epoch
    • None has an env alias; the trust root lives in config on purpose.
  • mcpproxy security bundle [history|rollback|unpin|reload] and GET/POST /api/v1/security/bundle{,/history,/rollback,/unpin,/reload}. Mutations are refused to agent tokens.

  • cmd/tpa-db keygen|sign|verify for operators who sign their own corpus.

  • Bundle status adds:

    • full_digest, sequence, authority, trust_epoch
    • signature_verified, staleness_days
    • coverage_degraded and degraded_reason
    • pinned, rollback, and last_rejection{reason,detail,at}

    These show on the CLI, REST and the Web UI Security page, which has a degraded banner.

  • Docs: docs/features/tpa-bundle-signing.md covers the full operator lifecycle, including an air-gapped walkthrough and the key-compromise procedure. Also updated: security-quarantine, tool-scanner, security-commands and configuration.

How it works

  • Verify before parse (FR-006/FR-008). This is a source-neutral Candidate{bytes, sidecar, source} pipeline in scanner/tpa_candidate.go, so Spec 087's fetch adapter reuses the verifier rather than forking it. Stages run in order:

    1. sidecar cap (4 KiB) and total grammar
    2. bundle ceiling (8 MiB)
    3. Ed25519 over the raw bytes
    4. the active-root check
    5. one parse of the verified buffer
    6. rule ceiling (2000)
    7. sequence, epoch and key-id agreement
    8. deny-list and watermark
    9. compile and runnable
    10. self-check hook
    11. activation
  • Candidate open (candidate_open_{unix,windows}.go):

    • POSIX opens with O_NOFOLLOW|O_NONBLOCK, then fstats the descriptor.
    • Windows uses CreateFile with FILE_FLAG_OPEN_REPARSE_POINT and refuses reparse points.
    • Only regular files are read, and the read is bounded from the same descriptor.
    • A 5 s budget is enforced by a context-cancelled reader, never SetReadDeadline.
    • At most one parked reader is allowed per path.
  • Anti-downgrade (FR-004a/004b/005a/009/009b):

    • State is per signing authority and scoped to an epoch.
    • Each watermark is seeded from its baseline. The publisher baseline is the embedded corpus's sequence, re-applied on every start. The operator baseline comes from config.
    • A signature ratchet refuses unsigned bundles once a signed one has activated.
    • Exactly one active root, declared in config. Switching it deactivates a bundle served by the other root.
  • Crash consistency (FR-011a):

    1. The last-known-good file is written content-addressed to <datadir>/tpa-lkg/<sha256>.json (plus .<sidecar-sha256>.sig): temp file, fsync, rename, directory fsync.
    2. One BBolt Update then writes the state and the history row to the new tpa_bundle_state bucket.
    3. Only then is the bundle published in memory.
  • Rollback (FR-010):

    • It requires the full SHA-256. The 12-hex fingerprint is refused as a usage error.
    • The digest is compared before any parse.
    • The watermark is never lowered. Rollback pins the target and deny-lists the release rolled away from.
    • Tests use a real 48-bit fingerprint-prefix collision, found by birthday search: two bundles share 6cd669dbf408.
  • Coverage (FR-007/009a/009b): coverageOK and the async scan-mode admission path both stop auto-approving under exactly four conditions:

    • an unsigned external bundle is active
    • the embedded default is below the publisher watermark
    • the operator root's bundle is missing or invalid
    • an operator bundle with no baseline is active

    The must-not-degrade cases are tested too.

  • Telemetry (FR-021, D7): the schema goes to 15. There are four typed tpa_scanner keys, and the anonymity backstop validates them as a whole tuple. The provenance gate means only publisher-authority bundles report the version line and sequence.

Assumptions and decisions (no maintainer input requested)

Recorded in full in specs/101-tpa-db/tasks.md, which also has the 2026-10-20 delta against plan.md.

  • The publisher key set ships empty. No publisher key has been issued, so any publisher-signed bundle is refused with key_not_trusted. The operator root is the only signed path until a release adds a key.
  • The sidecar has four keys, not five. The contract wins over plan D3, data-model and quickstart, which said five.
  • Config keys are renamed to tpa_operator_signers and tpa_active_root. GET /api/v1/config masks any key containing KEY or AUTH. Public keys are restored only in the typed config read door, and only when each value is a canonical 32-byte key.
  • Pin semantics. While a pin binds, only the pinned digest, or a release strictly above the watermark, may activate. That holds for unsigned candidates too.
  • An epoch change re-scopes the authority. Pins and deny entries are retired. ExternalEverActivated is kept, so an operator epoch bump still reports operator_bundle_missing.
  • Baselines are exclusive for activation and inclusive for rollback (A11).
  • There is no file watcher on the bundle. It activates on config reload, on restart, or via security bundle reload.
  • The activation self-check is an injectable hook with a no-op default. The real gate is US2 D8 / Spec 087.

Review record

  • Internal: an Opus judge on the core found 1 major and 5 minor defects, all fixed. A 7-dimension review then ran with 3 adversarial refuters per finding: 19 findings, 9 survived plus 1 override, all fixed with tests proven by mutation.
  • Cross-model: opencode GPT-6.1 Sol (variant high), in 9 file-scoped chunks over 4 rounds.
    • Round 1 raised 6 findings. 5 were fixed. One (activation at exactly the baseline) was rejected as by design and recorded as A11.

    • Round 2 raised 4 findings, all fixed:

      • tpa-db: chmod on the descriptor, and a no-follow non-blocking open.
      • LKG directory: anchored through os.Root, and the parent fsync is now unconditional.
    • Round 3 raised 1 finding (the sidecar_too_large code in tpa-db verify), fixed.

    • Round 4: all Go chunks are CLEAN: sig, open, eval, state, runtime, rollback, wiring, surface.

    • Web UI chunk: 3 small findings fixed:

      • Review refreshes the cached status.
      • The epoch label no longer blames the binary.
      • The tamper label covers a key-id mismatch.

      One P2 was declined: a trust_epoch above 2^53 would render rounded in JS. Epochs advance by one per key-compromise event, and the backend compares them exactly as uint64.

  • A live walkthrough on an isolated instance exercised the full operator lifecycle: keygen, sign, drop, reload, downgrade, tamper, unsigned refusal, history, short-fingerprint refusal, digest-mismatch refusal, rollback with pin and deny-list, restart persistence, and a root switch and back.
  • That walkthrough found and fixed a UI gap. Since Spec 109, /security redirects to /review, so the bundle panel was unreachable. It is now on Review, and Home shows a degraded banner.

Verified on a live instance

This ran on head 3c25ac1: go build, a scratch data_dir, port 18711, and a throwaway Ed25519 key from tpa-db keygen. The config used the operator root, tpa_operator_baseline: 1 and require_signed_bundle: true. Bundles were the embedded corpus plus sequence, trust_epoch: 0, key_id and generated_at, signed with tpa-db sign. This follows specs/101-tpa-db/quickstart.md. The output below is trimmed with jq.

$ mcpproxy security bundle -o json   # step 4: signed seq 2 dropped
{"source":"file","sequence":"2","authority":"operator","signature_verified":true,"coverage_degraded":false,"pinned":null,"rollback":null,"full_digest":"e20251e44fa55925","last_rejection":null}

$ printf x >> scanner-bundle.json && mcpproxy security bundle reload   # tamper one byte
{"source":"file","sequence":"2","authority":"operator","signature_verified":true,"coverage_degraded":false,"pinned":null,"rollback":null,"full_digest":"e20251e44fa55925","last_rejection":{"reason":"tamper","detail":"signature_invalid: signature does not verify under key 432b3fd0131cbecf1857c50a8d657048dd84f6df39aaf3dda73f02b449674287"}}

$ cp v1/scanner-bundle.json* . (seq 1 < watermark 2) && mcpproxy security bundle reload   # replay older
{"source":"file","sequence":"2","authority":"operator","signature_verified":true,"coverage_degraded":false,"pinned":null,"rollback":null,"full_digest":"e20251e44fa55925","last_rejection":{"reason":"downgrade","detail":"sequence 1, watermark 2, set by e20251e44fa5"}}

$ mcpproxy security bundle history -o json
{"sequence":"2","full_digest":"e20251e44fa55925","source":"file","signature_verified":true}

$ mcpproxy security bundle rollback --path v1/scanner-bundle.json --digest <full>   # outside the bundle dir
Error: failed to roll back bundle: invalid bundle rollback request: the rollback target must be a file directly in the bundle directory $SCRATCH

$ mcpproxy security bundle rollback --path scanner-bundle.v1.json --digest <12-hex>
Error: --digest must be the full SHA-256 of the target: 64 lowercase hex characters (got 12 characters); the 12-character fingerprint is a label, not an authorization - the full digest is required, see `mcpproxy security bundle history`

$ mcpproxy security bundle rollback --path scanner-bundle.v1.json --digest <wrong full digest>
Error: bundle rollback refused (rollback_target_mismatch): named e20251e44fa5, the file holds ab3cf57ba639; the last-known-good bundle keeps serving

$ mcpproxy security bundle rollback --path scanner-bundle.v1.json --digest <full digest of v1>
{"source":"file","sequence":"1","authority":"operator","signature_verified":true,"coverage_degraded":false,"pinned":true,"rollback":true,"full_digest":"ab3cf57ba639d2aa","last_rejection":{"reason":"rollback_target_mismatch","detail":"named e20251e44fa5, the file holds ab3cf57ba639"}}

$ cp v2.json* to the bundle path && mcpproxy security bundle reload   # rolled-away release
{"source":"file","sequence":"1","authority":"operator","signature_verified":true,"coverage_degraded":false,"pinned":true,"rollback":true,"full_digest":"ab3cf57ba639d2aa","last_rejection":{"reason":"deny_listed","detail":"release e20251e44fa5 (sequence 2) was rolled away from"}}

$ restart mcpproxy serve && mcpproxy security bundle -o json
{"source":"file","sequence":"1","authority":"operator","signature_verified":true,"coverage_degraded":false,"pinned":true,"rollback":true,"full_digest":"ab3cf57ba639d2aa","last_rejection":{"reason":"deny_listed","detail":"release e20251e44fa5 (sequence 2) was rolled away from"}}

CI notes

  • CodeQL go/path-injection (2 alerts) is fixed in 5d99de4:
    • A rollback --path must now be a file directly in the directory of tpa_bundle_path. Anything else is a usage error, refused before anything is opened, so the rollback door cannot probe arbitrary files.
    • The unsigned-drop mtime fallback now reads the mtime from the descriptor the bytes came through, not from a second os.Stat of the path.
  • Windows: bundlesig fixtures are byte-exact, so .gitattributes now pins them -text.

Out of scope / follow-ups

  • US2 owns signatures[], the seed corpus, stamping the embedded sequence/trust_epoch, scan-eval --bundle, and the contributor docs. A hand-over note is in the shared session notes.
  • US3 / Spec 087 owns the publication channel, network fetch and daily tick, the real self-check, the post-activation re-scan, and shipping the first publisher key.

…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
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
…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
… 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
… 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
…de 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
…llback 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
…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
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
…age 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
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
…ar 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 <bundle>.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
…sync, 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
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 74074b2
Status: ✅  Deploy successful!
Preview URL: https://2730cf3b.mcpproxy-docs.pages.dev
Branch Preview URL: https://claude-101-tpa-db-format-8c7.mcpproxy-docs.pages.dev

View logs

Comment thread internal/security/scanner/candidate_open_unix.go Fixed
Comment thread internal/security/scanner/tpa_bundle_runtime.go Fixed
…ar 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
…w 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
…tory

Every directory-level operation on <dataDir>/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
…ed 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
…-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
…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
… 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
Windows reports a path under a regular file as ERROR_PATH_NOT_FOUND.

Related #938
…mat-8c7227

# Conflicts:
#	CLAUDE.md
#	oas/docs.go
@github-actions

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: claude/101-tpa-db-format-8c7227

Available Artifacts

  • archive-darwin-amd64 (32 MB)
  • archive-darwin-arm64 (28 MB)
  • archive-linux-amd64 (19 MB)
  • archive-linux-arm64 (17 MB)
  • archive-windows-amd64 (31 MB)
  • archive-windows-arm64 (28 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (28 MB)
  • installer-dmg-darwin-arm64 (25 MB)
  • smart-mcp-proxymcpproxy-goRNU9IK.dockerbuild (0 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 39478785002 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

@Dumbris
Dumbris merged commit 14cb288 into main Oct 20, 2026
62 of 63 checks passed
@Dumbris
Dumbris deleted the claude/101-tpa-db-format-8c7227 branch October 20, 2026 20:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants