Skip to content

Remove unused isDatabaseOpen method from Manager struct to streamline code and improve maintainability. - #4

Merged
Dumbris merged 1 commit into
mainfrom
bubfix/linter-fixs
Jun 30, 2025
Merged

Dumbris merged 1 commit into
mainfrom
bubfix/linter-fixs

Conversation

@Dumbris

@Dumbris Dumbris commented Jun 30, 2025

Copy link
Copy Markdown
Member

No description provided.

@Dumbris
Dumbris merged commit a8040e5 into main Jun 30, 2025
@Dumbris
Dumbris deleted the bubfix/linter-fixs branch June 30, 2025 06:36
rannow pushed a commit to rannow/mcpproxy-go that referenced this pull request Sep 23, 2025
Dumbris added a commit that referenced this pull request Nov 2, 2025
Implements Issues #1, #2, #4, and #11 (Layers 1, 3, 4) from docker-recovery-improvements.md

**Issue #11: Duplicate Container Spawning Prevention**
- Layer 1 (Idempotent Creation): ensureNoExistingContainers() cleans up all existing containers before creating new ones
- Layer 3 (Distributed Locks): Per-server mutex prevents concurrent container creation races
- Layer 4 (Enhanced Health Verification): verifyContainerHealthy() checks Docker container state AND responsiveness

**Issue #1: Docker-only Filtering**
- ForceReconnectAll() now filters to only reconnect Docker-based servers
- HTTP/SSE/stdio servers are skipped, preventing unnecessary reconnections

**Issue #2: Container Health Verification**
- verifyContainerHealthy() detects frozen containers when Docker paused
- Uses docker inspect to verify container running + responsive
- Prevents skipping reconnection for zombie containers

**Issue #4: Add Recovering State**
- New StateCoreRecoveringDocker state provides UX feedback during recovery
- EventDockerRecovered triggers transition from error → recovering → launching
- Tray menu shows "Docker engine recovered - reconnecting servers..."

**Files Modified:**
- internal/upstream/core/docker.go: ensureNoExistingContainers(), GetContainerID()
- internal/upstream/core/connection.go: container lock acquisition, pre-cleanup
- internal/upstream/core/container_lock.go: NEW - distributed lock implementation
- internal/upstream/core/client.go: GetContainerID() accessor
- internal/upstream/manager.go: verifyContainerHealthy(), Docker-only filtering
- internal/upstream/managed/client.go: GetContainerID() delegation
- cmd/mcpproxy-tray/internal/state/states.go: StateCoreRecoveringDocker, EventDockerRecovered
- cmd/mcpproxy-tray/main.go: state mapping, handleDockerRecovering()
- internal/tray/connection_state.go: ConnectionStateRecoveringDocker

**Testing:**
- All upstream package tests passing (./internal/upstream/...)
- Code compiles cleanly (go build ./...)
- State machine transitions validated

**Impact:**
- Prevents duplicate container spawning during concurrent reconnections
- Eliminates resource exhaustion from orphaned containers
- Better UX - users see recovery in progress instead of errors
- Only Docker servers affected by Docker recovery (not HTTP/SSE)
technicalpickles added a commit to technicalpickles/mcpproxy-go that referenced this pull request Dec 1, 2025
Enhanced docs/oauth-zero-config.md with detailed documentation for:

1. Server States Section:
   - Connected states: ready (authenticated), connected (no token)
   - Waiting states: pending_auth (normal waiting state, not an error)
   - Transitional states: connecting, authenticating
   - Error states: disconnected, error

2. Checking Authentication Status:
   - How to use `mcpproxy auth status` command
   - Example output with emoji indicators (✅⏳❌)
   - Status meaning explanations

3. Troubleshooting Section with 4 Common Issues:

   Issue smart-mcp-proxy#1: Server Shows "Pending Auth" State
   - Symptoms: ⏳ icon in tray, pending_auth status
   - Cause: OAuth detected but user hasn't authenticated
   - Solution: Use `auth login` or tray "Authenticate" action
   - Clarification: NOT an error, intentional deferral

   Issue smart-mcp-proxy#2: Authentication Token Expired
   - Symptoms: Was working, now shows "Auth Failed"
   - Cause: OAuth token expired (1-24 hour lifetime)
   - Solution: Re-authenticate with `auth login`

   Issue smart-mcp-proxy#3: OAuth Detection Not Working
   - Symptoms: No pending_auth, just connection failures
   - Diagnosis: Check doctor output, logs, manual OAuth test
   - Common causes: Non-standard endpoints, firewall issues
   - Solution: Add explicit OAuth configuration

   Issue smart-mcp-proxy#4: OAuth Login Opens Browser But Fails
   - Symptoms: Browser opens, approval given, but still fails
   - Diagnosis: Check callback logs for authorization code
   - Common causes: Port conflict, timeout, firewall
   - Solution: Retry with debug logging

4. Diagnostic Commands Reference:
   - doctor: Quick OAuth detection check
   - auth status: View token status
   - upstream list: Check connection status
   - upstream logs: View OAuth flow logs
   - auth login --log-level=debug: Test with debug output
   - upstream list --format=json | jq: Verify OAuth config

This addresses user confusion about "Pending Auth" being displayed as an
error state. Documentation now clearly explains it's a normal waiting state
and provides step-by-step troubleshooting for all OAuth-related issues.

Related to PR smart-mcp-proxy#165: Zero-config OAuth with RFC 8707 support
Task: Update documentation for OAuth server states and troubleshooting
Dumbris pushed a commit to nlaurance/mcpproxy-go that referenced this pull request May 18, 2026
…mcp-proxy#468 smart-mcp-proxy#1-smart-mcp-proxy#4)

smart-mcp-proxy#1 (bug): config-denied tools returned the generic 'Tool is disabled
and not callable' over MCP — identical to a user-toggled tool, but the
remediation differs (edit mcp_config.json vs a UI toggle that 409s).
blockedToolMessage() now branches on IsToolConfigDenied and tells the
agent it is operator policy, not user-overridable. Split into a pure
blockedToolMessageFor(bool) with a dedicated unit test.

smart-mcp-proxy#2: config-lock badges badge-error (red/alarming) -> badge-neutral + 🔒;
a policy lock is not an error.
smart-mcp-proxy#3: 'Enable All' toast now reports 'N tools remain locked by config'
(client-side from config_denied; no API contract change).
smart-mcp-proxy#4: unified copy — stray 'config locked' label -> '🔒 locked by config'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dumbris pushed a commit that referenced this pull request May 18, 2026
…ggles (#468)

* feat(config): add enabled_tools/disabled_tools per-server allowlist/denylist

Adds two mutually exclusive fields to ServerConfig that let operators
declare tool visibility statically in mcp_config.json rather than
having to call the API or CLI after every fresh install.

  enabled_tools: ["list_issues", "get_issue"]   // allowlist — only these visible
  disabled_tools: ["delete_repo", "force_push"]  // denylist  — hide these, allow rest

Config validation rejects a server that has both fields set.

On every applyDifferentialToolUpdate (server connect / tool refresh),
applyConfigToolFilter walks the in-memory config, computes the desired
enabled/disabled state for each discovered tool, and calls
setToolEnabledNoEmit to persist it in BBolt. All existing enforcement
paths (isToolCallable, retrieve_tools pre-ranking, call_tool_*) pick
up the change automatically with no further modifications.

Five unit tests cover: allowlist disables unlisted tools, allowlist
re-enables a tool moved back into the list, denylist disables listed
tools, no-op when neither field is set, and end-to-end integration
through applyDifferentialToolUpdate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(config): add IsToolAllowedByConfig helper on ServerConfig

* feat(runtime): replace BBolt-writing applyConfigToolFilter with IsToolConfigDenied

Replace the BBolt-writing applyConfigToolFilter function (which overwrote
user preferences on every reconnect, emitted spurious audit events, and had
no provenance) with an evaluation-time IsToolConfigDenied method that:

- Reads config at call time, never writes to BBolt
- Preserves user-set tool preferences and audit trail
- Enables separation of concerns: config vs user intent

Key changes:
- Delete applyConfigToolFilter from tool_quarantine.go (63 lines removed)
- Add IsToolConfigDenied(serverName, toolName string) bool to Runtime
- Remove applyConfigToolFilter call from lifecycle.go
- Rewrite tool_config_filter_test.go: 5 new tests for IsToolConfigDenied
  * AllowList: tools not listed are denied
  * DenyList: listed tools are denied
  * NoFilter: all tools allowed when config has no filter
  * UnknownServer: returns false for missing servers
  * UserDisabledPreserved: BBolt state is independent from config layer

All 198 runtime tests pass. No behavior change to actual tool visibility—
the config layer is now just evaluated at call time instead of persisted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(runtime): skip config-denied tools in SetAllToolsEnabled bulk toggle

* feat(server): enforce config tool filter in isToolCallable

Add IsToolConfigDenied delegation on *Server and insert a config-layer
check in isToolCallable so tools denied by enabled_tools/disabled_tools
in the server config are hard-off at MCP call time, evaluated at
runtime without touching BBolt storage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(api): expose config_denied on tool response; reject enabling config-denied tools

- Add ConfigDenied bool field to contracts.Tool (json: config_denied,omitempty)
- Enrich config_denied in handleGetServerTools via IsToolConfigDenied interface
- Return HTTP 409 in handleSetToolEnabled when req.Enabled is true for a config-denied tool
- Remove debug fmt.Printf lines from enrichment loop; use logger.Debug instead

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ui): show locked badge and disable toggle for config-denied tools

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(types): add disabled/config_denied fields to Tool interface

Extended the Tool interface to include runtime fields that the backend
sends and Vue components use: server_name, schema, usage, last_used,
approval_status, disabled, and config_denied. This allows proper typing
of these fields in the frontend instead of using unsafe casts.

Simplified type assertions in isToolConfigDenied and isToolEnabled
functions to use the properly-typed Tool interface directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(storage): persist EnabledTools/DisabledTools fields through BBolt round-trip

* chore(oas): regenerate OpenAPI spec with config_denied tool field

* chore: trigger GitHub merge-check recalculation

* fix(ux): status-aware TOOL_BLOCKED + lock-badge polish (review #468 #1-#4)

#1 (bug): config-denied tools returned the generic 'Tool is disabled
and not callable' over MCP — identical to a user-toggled tool, but the
remediation differs (edit mcp_config.json vs a UI toggle that 409s).
blockedToolMessage() now branches on IsToolConfigDenied and tells the
agent it is operator policy, not user-overridable. Split into a pure
blockedToolMessageFor(bool) with a dedicated unit test.

#2: config-lock badges badge-error (red/alarming) -> badge-neutral + 🔒;
a policy lock is not an error.
#3: 'Enable All' toast now reports 'N tools remain locked by config'
(client-side from config_denied; no API contract change).
#4: unified copy — stray 'config locked' label -> '🔒 locked by config'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Dumbris added a commit that referenced this pull request Jun 29, 2026
…ess (MCP-34.5) (#782)

* qa(sandbox): MCP-3236 integration tests + CI workflow + snap-docker harness

- .github/workflows/sandbox-integration.yml: dedicated CI job on ubuntu-latest
  (kernel 6.8, Landlock ABI 3) — runs sandbox package tests, upstream/core
  wrapper integration tests, scanner isolation-mode degradation tests, binary
  build, and server startup probe with isolation.mode=sandbox

- docs/development/sandbox-snap-docker-harness.md: manual harness for Ubuntu
  snap-docker hosts — negative baseline (mode=docker → AppArmor failure
  reproducing GH #71) and positive case (mode=sandbox → Landlock confinement,
  scanner graceful degradation)

- docs/qa/mcpproxy-qa-mcp3236-2026-06-29.html: HTML QA report (10/11 pass,
  1 skip — linux-only Landlock tests skip on darwin as designed)

Satisfies exit criterion #4 of MCP-34 (MCP-3236).

* ci(sandbox): poll for running:True in health probe (fix MCP-3236 startup race)

The 'Verify server health' step checked /api/v1/status once, immediately after
the start step's readiness loop broke on the first HTTP-200 — but the server
responds to /status before it finishes warming up (Bleve index, capability
registration), so 'running' was still False and the step failed on CI.

Retry for running:True up to 30s before failing.

Related #71

* ci(sandbox): check status.phase==Ready, not nonexistent running field

The health probe checked d.get('running') in /api/v1/status, but the response
shape is {"status": {"phase": "Ready"}} — there is no top-level 'running'
field, so the check was always False even though the server was up and serving.
Poll for status.phase == Ready instead.

Related #71

* ci(sandbox): poll /readyz (controller-backed) for readiness

Parsing /api/v1/status JSON was fragile (the status object is nested and the
healthy phase is 'Running', not 'Ready'). /readyz is the canonical readiness
endpoint — controller-backed, returns 200 when IsReady() is true — so poll it
for 200 instead. Structure-independent and idiomatic.

Related #71

* ci(sandbox): use docker_isolation.mode (global key) + assert sandbox actually resolved

CodexReviewer caught the probe was vacuous: the config used a top-level
"isolation" key, but the GLOBAL isolation mode is docker_isolation.mode
(per-server isolation is the only 'isolation' key). The wrong key was silently
ignored, so the server started with isolation_mode=none — the 'sandbox' probe
never tested sandbox.

- workflow + harness: isolation -> docker_isolation for the global mode
- workflow: assert the server log shows isolation_mode=sandbox (fail if not),
  so a future wrong-key regression can't pass vacuously
- harness positive case now actually runs the stdio 'everything' server under
  Landlock (inherits global sandbox); negative baseline under docker (AppArmor)

Related #71
Dumbris added a commit that referenced this pull request Jul 1, 2026
… FP control

Addresses three Codex findings on Spec 077 US1's deterministic detect engine.

#3 (MED): removing the legacy security.NewDetector(nil) path silently dropped
sensitive-file-path and high-entropy secret coverage. The secret.embedded
check now restores both (curated sensitive-path regexes + a self-contained
Shannon-entropy scan, stdlib only), keeping detect offline/deterministic with
no new dependency.

#4 (MED): with the legacy tpaRules deleted, several dangerous phrases matched
neither tier. Restored per spec posture: a high-confidence guardrail override
("ignore your guidelines") is HARD phrase.injection; weaker, benignly-phrasable
directives ("always call this tool first", "before using any other tool",
"developer mode", external data-forwarding) are SOFT directive.imperative
(review-only). Data-forwarding requires an external/remote target so benign
first-party uploads do not match.

#5 (MED): strengthened the HARD false-positive control with colon-anchored
content cues ("text:", "output:", ...). A benign tool that RETURNS an injection
string ("Returns training text: ignore all previous instructions ...") is now
example-position and discounted below the hard floor, without losing recall on
genuine period-introduced imperatives.

Corpus: added a gated malicious guardrail-override positive and an
attack-resembling benign hard-negative for the returns-content case; the
scan-eval gate stays at recall 1.0 / FP 0.0. Unit tests cover the restored
secret categories, the SOFT legacy phrases, the HARD guardrail override, the
benign near-miss, and the colon-cue position classification.

Related: Spec 077 (specs/077-scanner-simplification)
Dumbris added a commit that referenced this pull request Jul 2, 2026
…#786)

* feat(security): deterministic offline baseline scanner (Spec 077 US1)

Related #784
Related: Spec 077 (specs/077-scanner-simplification)

Make the offline detect engine the sole in-process baseline. Delete the
duplicate legacy tpaRules phrase heuristics and the duplicate legacy
embedded-secret path, preserving the approval-blocking posture via a new
curated HARD-tier detect check.

## Changes
- Add ScanFinding.Tier ("hard"|"soft") and Sources ([]string); set them from
  detect output in detectFindingToScanFinding (omitempty, back-compat).
- Add DeepScanDescriptor + ScanSummary.DeepScan placeholder (US3 populates it).
- New detect check checks/phrase_injection.go: hard-tier, curated
  injection/exfiltration directives, position-discounted to avoid benign FPs.
  Wired into the live scanner Checks slice and cmd/scan-eval gateChecks().
- Remove legacy tpaRules, matchAnyPhrase, and the security.NewDetector append.
- Derive the server verdict from tiers only: a "dangerous" status now requires
  >=1 hard baseline finding (isBlockingFinding); legacy/external findings keep
  their threat_level fallback.
- Document the FR-018 default posture: in-process scanner enabled, Docker
  scanners disabled (Status-driven).
- Extend detect_corpus_v1.json with 7 phrase_injection positives and 4 benign
  near-misses; add phrase_injection to the gate categoryCheck.

## Testing
- phrase_injection recall + benign no-block; corpus no-coverage-loss;
  determinism with nil Docker runner; default-enablement.
- go test -race ./internal/security/... ./cmd/scan-eval/... green.
- scan-eval --gate: recall 1.0000 (>=0.90), fp 0.0000 (<=0.05), phrase_injection
  gated 7/7.
- golangci-lint v2 clean.

* feat(security): gate Approve modal on baseline dangerous findings (Spec 077 US1)

Related #784
Related: Spec 077 (specs/077-scanner-simplification)

The server Approve confirmation now blocks on baseline DANGEROUS (hard-tier)
findings only (FR-021), mirroring the tier-driven server verdict, instead of
`critical` severity — a non-blocking soft finding can be high/critical severity
yet must not gate approval. Applied to both ServerDetail.vue and ServerCard.vue
(same approval gate), with the dialog wording updated to "dangerous".

## Testing
- vue-tsc --noEmit clean; vite build succeeds.

* test(security): recognize phrase_injection as a gated detect category (Spec 077 US1)

Related #784
Related: Spec 077 (specs/077-scanner-simplification)

The detect-corpus validator (specs/065-evaluation-foundation/datasets) hardcodes
the set of coherent malicious categories and the gated-category coverage rules.
Spec 077 US1 promoted phrase_injection to a real gated hard category (registered
in cmd/scan-eval gateChecks + categoryCheck), so the validator must recognize it
or reject the new corpus entries.

## Changes
- validDetectCategory: accept malicious category "phrase_injection".
- gatedDetectCategories: add "phrase_injection" (now measured by the gate;
  capability_mismatch stays excluded — soft/measured-not-gated).
- hardNegPrefix: map "phrase_injection" -> "hn_phrase".
- Rename the two branch-local phrase_injection hard-negatives
  (hn_send_email/hn_upload_file -> hn_phrase_*) to satisfy the id-prefix
  convention. Pre-existing corpus entries untouched (append-only respected).

This STRENGTHENS coverage: the gate now requires phrase_injection to carry both
malicious samples and resembling hard-negatives.

## Testing
- go test ./... — all ok (exit 0); previously-failing
  TestDetectCorpus_SchemaAndProvenance + TestDetectCorpus_GatedCoverage pass.
- scan-eval --gate — recall 1.0000, fp 0.0000 (phrase_injection gated 7/7).
- golangci-lint v2 clean.

* fix(security): tier-driven approval gate + preserve baseline classification

Addresses two Codex findings on the Spec 077 US1 two-tier scanner model.

#1 (HIGH): ApproveServer gated only on Summary.Critical, so a HARD-tier
phrase.injection finding (SeverityHigh, not Critical) let a dangerous server
be unforce-approved. The gate now blocks on any isBlockingFinding — the SAME
predicate that drives the "dangerous" summary verdict, so the gate and the
verdict can never disagree — while critical severity still blocks for
back-compat and --force still overrides.

#2 (HIGH): ClassifyThreat re-derived threat_level from description keywords,
which could downgrade a HARD baseline finding dangerous->warning while its
Tier stayed "hard", breaking the tier<->level coupling. It now returns early
for any finding that already carries a Tier (baseline detect output);
legacy/external findings (no Tier) are still classified as before.

Tests: a High-severity hard phrase_injection cannot be unforce-approved but
can with --force; a soft finding never blocks; ClassifyThreat leaves a hard
baseline finding dangerous and still classifies legacy findings.

Related: Spec 077 (specs/077-scanner-simplification)

* fix(detect): restore secret + legacy-phrase coverage; strengthen HARD FP control

Addresses three Codex findings on Spec 077 US1's deterministic detect engine.

#3 (MED): removing the legacy security.NewDetector(nil) path silently dropped
sensitive-file-path and high-entropy secret coverage. The secret.embedded
check now restores both (curated sensitive-path regexes + a self-contained
Shannon-entropy scan, stdlib only), keeping detect offline/deterministic with
no new dependency.

#4 (MED): with the legacy tpaRules deleted, several dangerous phrases matched
neither tier. Restored per spec posture: a high-confidence guardrail override
("ignore your guidelines") is HARD phrase.injection; weaker, benignly-phrasable
directives ("always call this tool first", "before using any other tool",
"developer mode", external data-forwarding) are SOFT directive.imperative
(review-only). Data-forwarding requires an external/remote target so benign
first-party uploads do not match.

#5 (MED): strengthened the HARD false-positive control with colon-anchored
content cues ("text:", "output:", ...). A benign tool that RETURNS an injection
string ("Returns training text: ignore all previous instructions ...") is now
example-position and discounted below the hard floor, without losing recall on
genuine period-introduced imperatives.

Corpus: added a gated malicious guardrail-override positive and an
attack-resembling benign hard-negative for the returns-content case; the
scan-eval gate stays at recall 1.0 / FP 0.0. Unit tests cover the restored
secret categories, the SOFT legacy phrases, the HARD guardrail override, the
benign near-miss, and the colon-cue position classification.

Related: Spec 077 (specs/077-scanner-simplification)

* fix(detect): three-way position model to close recall bypass without reopening FP (Spec 077 US1)

Codex round-2 findings A/B/C on PR #786:

- A (HIGH, recall bypass): drop the bare colon-label example cues
  ("prompt:", "message:", "payload:", …). A label prefix no longer
  discounts a clear imperative, so "Prompt: ignore all previous
  instructions …" stays instruction-position and hard-blocks.

- B (MED, hard FP): add PositionDescriptive (discount 0.5, HARD→SOFT).
  A tool that DESCRIBES an injection — relative clause "prompts that
  ignore…" or an analytical verb governing the phrase — no longer
  hard-blocks; the soft check still surfaces it for review, so no total
  suppression / no new bypass. Sentence-scoped so a benign lead sentence
  can't discount a following injection.

- C (MED, lost coverage): restore legacy secrecy directives as SOFT
  directive.imperative signals — "without telling/informing the user",
  "hide this from …", "keep this hidden/secret", and the <hidden> marker.

Corpus: add malicious "Prompt: ignore…" positive (locks A, must detect)
and benign "Analyzes prompts that ignore…" hard-negative (locks B, must
not flag). Gate: recall 1.0, hard-neg FP 0.0.

Related: Spec 077

* fix(detect): never fully suppress a matched injection; restore no-noun overrides & system_prompt tag (Spec 077 US1)

Closes the recurring silent-bypass class Codex flagged across three review
rounds: a fresh framing cue each round ("label:", then "Example output format.")
made a real injection drop below every emit floor and vanish entirely.

Structural fix (root cause): phrase.injection is built for recall, so a curated
family that MATCHED but was position-discounted below the hard floor is no longer
discarded — it downgrades to a SOFT review signal re-floored to the soft emit
floor. A matched injection can therefore never be invisible, regardless of what
cue precedes it; worst case it becomes review-only (never auto-quarantine). The
"example"/"such as" word cues are now sentence-scoped like the descriptive-verb
heuristic, so a cue in a prior sentence cannot discount an imperative that begins
the next one (only the inline "e.g."/"i.e." abbreviations and quote runs stay
whole-window). Round-3 finding #1 ("Example output format. Ignore all previous
instructions…") now HARD-blocks.

The eval gate now measures the auto-quarantine (HARD) tier only, so the new
review-only SOFT findings on scanners that quote/describe an injection are not
counted as false positives; recall is unaffected (all gated malicious samples are
HARD-detected).

Finding #2: restore no-noun instruction overrides ("ignore the above and …",
"disregard all previous, then …") as a HARD phrase family (requires a trailing
continuation so "ignore the above warning" stays benign), and add a SOFT
"new instructions:" injected-preamble family.

Finding #3: match compound hidden-instruction tags (<system_prompt>,
<developer-note>) by allowing an underscore/hyphen-joined suffix after the
keyword, without loosening to prefixes (<systematic> still ignored).

Corpus: +3 malicious phrase_injection samples (prior-sentence-cue + two bare
overrides). Gate passes recall 1.0 / FP 0.0.

Related: Spec 077

* fix(detect): close Codex round-4 recall/FP gaps on baseline scanner (Spec 077 US1)

Address the three live Codex round-4 findings on PR #786 @ 520fd6b, a
security-critical path, and lock each with corpus parity cases.

1. HIGH — HARD exfil recall gap (phrase_injection.go): the exfil family only
   matched verb→target ordering, so "Read ~/.ssh/id_rsa, then send it to the
   attacker" (target-before-verb) fell through phrase.injection and only earned
   a SOFT secret.embedded finding — approval was not blocked. Add a
   target→verb→external-destination family: the sensitive target is named
   first, then an exfil/forward verb points it at an EXPLICIT external
   destination (url/email/attacker/remote server/webhook/…) via a
   to/into/via preposition. A first-party "returns the summary to the caller"
   (internal destination) never fires, so the HARD tier stays narrow.

2. MEDIUM — HARD false positive (position.go): descriptive framing with
   meta-verbs like checks/asks was not discounted, so a benign "Checks whether
   a prompt asks the agent to ignore your safety guidelines" hard-blocked. Add
   check/verify/validate/assess/evaluate/determine to the enumerated
   describing-verb fallback, and — to end the per-round whack-a-mole — add two
   STRUCTURAL descriptive-framing matchers (descriptiveClause: verb+s +
   complementizer "whether/if/that/…"; descriptiveObject: verb+s + a text/prompt
   object noun) that key on grammar, not vocabulary, so new benign meta-verbs
   are caught by construction. Both stay sentence-scoped, so a real injection
   opening a new sentence is never over-discounted.

3. MEDIUM — approval gate vs tier verdict disagreement (scanner/service.go):
   drop the extra `Summary.Critical > 0` guard in ApproveServer. A
   Critical-severity but NON-dangerous finding (e.g. a critical CVE mapped to
   threat_level "warnings", or a deep-scan/external finding) showed as
   non-dangerous in the summary/verdict yet still failed unforced approval. The
   gate is now purely tier-driven via isBlockingFinding — the SAME predicate
   that drives the "dangerous" summary — so gate and verdict can never disagree.
   A genuinely dangerous critical finding still carries threat_level "dangerous"
   and still blocks.

Corpus (append-only, self-authored): add pi_exfil_target_first (recall
positive, must gate), hn_phrase_return_to_caller and hn_phrase_meta_check
(benign near-misses, must NOT hard-block). scan-eval --gate PASSES:
recall=1.0000 (>=0.90), fp=0.0000 (<=0.05).

Related #786

Related: Spec 077

* fix(security): tier-driven approval gate + restore legacy sensitive-path coverage (Spec 077 US1)

Codex round-5 findings on PR #786:

#1 (HIGH) approval gate / verdict consistency: isBlockingFinding now blocks
iff Tier=="hard". Deep-scan/external/legacy findings carry no tier and no
longer gate approval or drive a "dangerous" verdict (US3 FR-021 — they inform
but never gate). Only the in-process baseline detect engine sets Tier, so US1
hard-block behavior (hard phrase_injection / hard detect) is unchanged. This is
the single predicate behind both the ApproveServer gate and the GetScanSummary
"dangerous" status, so gate and verdict can never disagree.

#2 (MEDIUM) embedded-secret file-path coverage: restore the legacy
security.NewDetector(nil) / paths.go GetFilePathPatterns() paths the detect
check had dropped — ~/.azure/accessTokens.json + azureProfile.json,
~/.docker/config.json, *.key, *.ppk, ~/.gitconfig, ~/.pypirc,
*service_account*.json, macOS ~/Library/Keychains/*, Windows
%LOCALAPPDATA%\Microsoft\Credentials\*, and <name>.env. Curated regexes mirror
paths.go (kept offline; detect cannot import internal/security, which pulls in
os) with a source-of-truth comment. Soft findings; new unit tests cover each
restored path plus benign non-matches.

#3 (ACCEPTED, no logic change): documented the sample/example-label
phrase-position false positive in position.go as a known, conservative
over-block (visible/quarantined/--force-able, not a silent bypass), tracked as
a follow-up.

Gate: recall=1.0 (>=0.90), fp=0.0 (<=0.05). Full suite + golangci-lint v2 green.

Related: Spec 077
Dumbris added a commit that referenced this pull request Jul 3, 2026
…791)

Add escalation trigger #4: when a PR is gated by a Codex cross-model review,
run at most 5 fix->re-review rounds on that PR; if not clean after the 5th,
stop and ask the human. Per-PR counter, resets each PR. Prevents endless
whack-a-mole on heuristic-style findings (e.g. the phrase-position tuning on
#786) while keeping the gate meaningful.

Related: Spec 077 (specs/077-scanner-simplification)
Dumbris added a commit that referenced this pull request Aug 8, 2026
Related #957

A Sparkle appcast has no architecture selector, and the release pipeline builds
one app bundle per architecture — each carrying a per-arch core in
Contents/Resources/bin — both reporting the same CFBundleShortVersionString. A
single merged feed would therefore hand an Intel user the Apple-Silicon build,
and the failure would arrive as a crash after a "successful" update.

Until a universal enclosure exists (open decision #4 in the decision report),
the pipeline publishes one feed per architecture and the tray asks for its own.

## Changes
- `Services/SparkleFeedURL.swift` — pure rewrite of the configured `SUFeedURL`:
  only the exact default file name `appcast.xml` becomes `appcast-<arch>.xml`.
  Any other name is used verbatim, so an operator already serving a merged or
  universal feed is not second-guessed.
- `SparkleFeedUpdater.feedURLString(for:)` applies it, returning nil (= "use
  Info.plist") when the rewrite is a no-op.
- The file names are half of a contract with `.github/workflows/release.yml`,
  which generates exactly those names; the test file says so.

## Testing
- `SparkleFeedURLTests` — 5 tests, 0 failures (default rewrite per arch, nested
  paths, operator-supplied names left alone, query strings preserved).
- `swift build` → Build complete, no "nearly matches optional requirement"
  warning, i.e. the delegate method really binds.
Dumbris added a commit that referenced this pull request Aug 8, 2026
…pdate CLI (spec 092)

* spec(092): one-click auto-updater (macOS) + channel-aware mcpproxy update CLI

Related #957

Spec for Option A from the decision research: Phase 0 stale-version
supersede (the actual #957 bug fix), Phase 1 finish the half-plumbed
Sparkle 2 integration, Phase 2 channel-aware CLI self-update.
Includes the multi-agent decision report under docs/research/.

* spec(092): codex round 1 — durable core ownership, unknown-channel guidance-only, enclosure-signature wording, RC pipeline parity

Related #957

- FR-001a: launch provenance must survive tray restarts (today every pre-existing core reads as external)
- FR-002: external-core restart = explicit user consent or instructions
- FR-020: self-update only on positively identified tarball channel; unknown stays guidance-only (detector never returns tarball today — positive marker is a prerequisite)
- FR-011: enclosure-level signature + bundle-identity codesign policy
- FR-014: prerelease pipeline must gain feed/manifest parity for RC channel
- FR-015: explicit hot-reloadable update policy contract governing all tray-side checks
- FR-017: single-source-of-truth menu state machine (feed owns one-click; legacy check is browser-guidance only)
- FR-021/022: defined confirmation point, backup/rollback, symlink/permission semantics; downgrade needs explicit version + --force
- FR-006: SemVer 2.0 precedence (rc.10 > rc.2)
- SC-001 scoped by ownership/acceptance; FR-030 premise corrected

* spec(092): codex round 2 — align P3 test/scenario with FR-020/FR-022 (unknown guidance-only; downgrade needs explicit version + --force)

Related #957

* feat(core): report durable launch provenance in /api/v1/info

Related #957

The tray already stamps MCPPROXY_LAUNCHED_BY=tray on every core it spawns
(CoreProcessManager.swift, cmd/mcpproxy-tray), and the macOS PKG postinstall
stamps installer — but the core never reported it back, so ownership lived
only in the launching tray's memory. A newer tray attaching to a core started
by an older tray classified it as external and could not supersede it: exactly
the #957 upgrade scenario (FR-001a).

## Changes
- internal/launch: capture MCPPROXY_LAUNCHED_BY once at process start and
  normalize it to "tray" / "installer" / "" (strict — an unrecognized marker
  never authorizes killing a process)
- GET /api/v1/info always carries launched_by (contracts.InfoResponse, oas,
  generated frontend contracts, docs/api/rest-api.md)
- mcpproxy status renders "Launched by:" when the core asserted a marker and
  stays quiet otherwise; JSON keeps it omitempty

## Testing
- go test -race ./internal/launch/... ./internal/httpapi/... ./cmd/mcpproxy/...
- golangci-lint v2 clean on the touched packages

* feat(updatecheck): make the tarball install channel positively detectable

Related #957

ChannelTarball existed since Spec 079 but detect() could never return it: the
release matrix stamped no build marker and no heuristic produces "tarball". A
positively identified tarball install is the precondition for `mcpproxy update`
to ever self-replace a binary (FR-020), so without it the CLI self-update path
is unreachable by construction.

## Changes
- release.yml builds a SEPARATE core binary for the .tar.gz/.zip archive,
  stamped with updatecheck.buildChannel=tarball, signed alongside the others on
  macOS, and verified post-build via `go version -m`. The shared matrix binary
  stays unstamped so the DMG/PKG bundle and the .deb/.rpm packages keep
  detecting their own channel.
- The tarball marker is deliberately WEAK in detect(): Homebrew installs the
  very same .tar.gz into its Cellar, so positive heuristics (brew prefix,
  container, package-manager path, .app bundle) override it and it only applies
  where detection would otherwise answer "unknown". Every other marker keeps
  absolute precedence.
- UpdateCommand(tarball) / PrereleaseUpdateCommand(tarball) => "mcpproxy update"
  (FR-024 for the tarball channel only; DMG guidance is unchanged until the
  tray updater ships).
- prerelease.yml documents why RC archives stay unstamped for now: self-update
  needs checksums.txt + cosign bundle, which that pipeline does not publish yet
  (FR-014).
- docs/features/version-updates.md: weak-marker rule and the updated command
  matrix.

## Testing
- go test -race ./internal/updatecheck/... (incl. a precedence table proving a
  stamped binary under a brew prefix / in a container / in an .app bundle /
  under dpkg ownership never resolves to tarball)
- golangci-lint v2 clean; both workflows re-parsed as YAML

* feat(cli): add channel-aware `mcpproxy update`

Related #957

`mcpproxy update` branches on the already-detected install channel (Spec 092
US3/FR-020..FR-023): package-manager installs get the exact upgrade command,
Docker/Windows-installer get guidance, a macOS app bundle is delegated to the
tray, and only a positively identified tarball install (or `unknown` plus an
explicit `--self` assertion) is ever self-replaced.

## Changes
- cmd/mcpproxy/update_cmd.go: the command, its decision table and the
  -o json/yaml report. --check reports current/latest/channel with no side
  effects and is also the output when nothing is newer. A development build is
  reported as such instead of "already up to date".
- cmd/mcpproxy/update_apply.go: checksum manifest parsing, sha256 verification,
  archive extraction, and the atomic swap — stage inside the target directory,
  preserve mode, rename target -> .old, rename new -> target, run
  `<new> --version` and only then drop the backup; restore on any failure.
  Symlinked launchers have their destination replaced, never the symlink.
- Refusals: app-bundle and staged-copy paths (checked independently of GOOS),
  non-writable targets (error names path + owner, never suggests sudo), and a
  downgrade without both --version and --force.
- internal/updatecheck: GetReleaseByTag + an overridable API base so an exact
  tag can be requested and tests can run against httptest.

## Signature verification — deliberate compromise (flagged)
FR-021 asks for offline signature verification. The sha256 check against the
release's checksums.txt is unconditional, and the cosign bundle over
checksums.txt is verified with the identity pinned to this repo's release
workflow — but via a locally installed cosign binary, not in-process.
sigstore-go v1.3.0 would add 72 linked modules (+16 MB to a hello-world
binary) and force a go.mod Go-directive bump; the decision report lists that
dependency as an open maintainer decision. Missing cosign therefore ABORTS the
update by default; --allow-unverified-signature is the explicit, loudly warned
opt-out.

## Testing
- go test -race ./cmd/mcpproxy/... ./internal/updatecheck/... — 130 test cases
  across the channel decision table, the downgrade/force matrix, bundle-path
  refusal, non-writable targets, and a real download->verify->swap against an
  httptest release server (happy path, tampered artifact, verify-failure
  restore, missing/failing signature, unlisted artifact)
- golangci-lint v2 (.github/.golangci.yml) on ./... — 0 issues
- go build -tags server ./cmd/mcpproxy + go test -tags server
  ./internal/serveredition/... -race

* fix(ci): make the tarball stamp self-check pipefail-safe

Related #957

The step runs under `bash -eo pipefail`, where `go version -m … | grep -q …`
can report the pipeline as failed when grep exits on the first match and go
takes SIGPIPE — turning the guard into a flaky release blocker. Capture the
build settings into a variable and match with `case` instead.

* feat(core): report the core pid in /api/v1/info

Related #957

FR-002 needs a stop mechanism for a core the tray only ATTACHED to: it
holds no Process handle for one, and the core exposes no shutdown
endpoint, so without a pid the consent action could only ever print
instructions. Paired with the launched_by provenance added in e186f5736
this is what lets a newer tray actually supersede a core an older tray
started.

## Changes
- contracts.InfoResponse.PID + the `pid` key in the handler payload,
  indirected through `var pidFn = os.Getpid` as the test seam
- regenerated frontend/src/types/contracts.ts (generator edited, not the
  output), oas/swagger.yaml + oas/docs.go via `make swagger`
- docs/api/rest-api.md field table and example

## Testing
- go test -race ./internal/httpapi/... ./internal/contracts/... — ok
- golangci-lint v2 (.github/.golangci.yml) — 0 issues

* fix(tray): compare versions by SemVer 2.0 precedence

Related #957

FR-006. The tray's only version comparison sorted prerelease identifiers
as whole strings, so 1.0.0-rc.10 ranked BELOW 1.0.0-rc.2. On the update
path that offers an RC user a downgrade as an "update"; the supersede
logic Phase 0 builds on top of it would have killed a newer core to
start an older one. Extracted into a shared type so both callers get the
same ordering and it has tests of its own.

## Changes
- new `SemanticVersion`: SemVer 2.0 parse + §11 precedence (numeric
  identifiers numerically, numeric < alphanumeric, prerelease < release,
  larger identifier set wins), tolerating a leading "v" and a two-part
  core, discarding build metadata per §10
- malformed input returns nil — "no decision" — instead of 0; only the
  update-nudge adapter (`UpdateService.compareSemver`) collapses that to
  0, where "not greater" already means "no update"

## Testing
- swift test --filter SemanticVersionTests: 13 tests, 0 failures

* feat(tray): supersede a stale core after an upgrade

Related #957

FR-001/FR-001a/FR-002/FR-005. After an upgrade the new tray finds the
OLD core still running: the previous tray spawned it, that tray is gone,
and nothing ever stopped it. The tray attached and served the old
version indefinitely — the reported bug.

Ownership was in-memory in the launching tray, so every pre-existing
core classified as external and could never be superseded. The core now
reports durable provenance (launched_by) and its pid, which is what
makes the decision possible at all.

## Changes
- `CoreSupersede.decide`: pure verdict — restartManaged (we hold the
  Process), stopAndRespawn (tray provenance + usable pid),
  askForConsent (user-launched, or tray-launched with no pid), or none
  with a logged reason. `installer` counts as tray provenance because
  launchCore deliberately preserves that marker on the core it spawns.
- `CoreProcessIdentity`: re-checks that a pid is an mcpproxy process
  immediately before signalling it — pids are recycled, and the core may
  have died between the info read and the signal.
- `BundledCore.respawnVersion`: asks the bundled binary (`version -o
  json`, a pure print — no config, no BBolt lock), falling back to
  CFBundleShortVersionString. Nil for dev builds and for a
  MCPPROXY_CORE_PATH override, where a restart would re-launch the same
  binary in a circle.
- `CoreProcessManager` evaluates after every connect (attach, launch,
  reconnect) — the only points a version report arrives — and publishes
  `AppState.staleCorePrompt` for the consent case. One attempt per
  session; shutdown retracts the offer.
- InfoResponse gains optional launched_by/pid (optional because a pre-092
  core omits them, and an old core is the whole subject).

## Testing
- swift test --filter CoreSupersede…|CoreProcessIdentity…|BundledCore…:
  21 tests, 0 failures
- swift test --filter CoreSupersedeAttachTests: 8 tests, 0 failures
  (real attach path against a Unix-socket stub core)
- swift test --filter ModelsTests: 97 tests, 0 failures

* feat(tray): offer a relaunch when the app bundle is replaced

Related #957

FR-003 and the menu half of FR-002. A drag-install replaces
/Applications/MCPProxy.app underneath the running process: macOS neither
notifies nor restarts it, so the old code — and the old core it manages
— keeps running until the user works out that they have to quit it.

## Changes
- `BundleUpdateWatcher` reads CFBundleShortVersionString from the
  Info.plist ON DISK on every check. Not `Bundle(path:)`: Foundation
  caches bundles by path and would keep answering with the pre-upgrade
  dictionary, which is exactly the state being detected. Offers only on
  a strictly newer, SemVer-comparable version — equal, older and
  unparseable all stay silent.
- Checked at launch, on every `applicationDidBecomeActive`, and on a
  5-minute timer.
- Menu: "MCPProxy was updated to vY — Relaunch" (stops the core, `open
  -n` the new bundle, then terminates — `-n` because plain `open`
  activates this stale process, the reported symptom) and "Old core vX
  running — Restart into vY" (FR-002 consent; with no pid to act on it
  presents instructions instead of failing silently).

## Testing
- swift test --filter BundleUpdateWatcherTests: 7 tests, 0 failures
  (incl. rewriting a fixture bundle's plist mid-test)
- swift test --filter SupersedeMenuTests: 6 tests, 0 failures (real
  controller + real rebuildMenu through the existing menu-host seam)

* fix(packaging): quit the running app before the installer launches the new one

Related #957

FR-004. `open -a` ACTIVATES a running instance instead of starting one,
so the postinstall step handed the user the OLD, just-overwritten app —
still serving from the old core — and called the upgrade done. Quitting
first is what makes the launch a launch.

## Changes
- politeness ladder: `osascript … to quit` by bundle id, 5s wait,
  SIGTERM (3s), SIGKILL. The app routes SIGTERM through its normal quit
  path, so even the fallback stops the managed core.
- the quit runs through the same `launchctl asuser … env -i` wrapper as
  the launch (factored into `run_as_user`) — an AppleScript sent from
  root's context cannot reach the user's app.
- pkill matches `MCPProxy.app/Contents/MacOS/MCPProxy`, not the bare
  name: a looser pattern also matches the core (whose orderly shutdown
  belongs to the tray) and this script's own command line.
- a survivor never fails the install; the new tray's stale-core
  supersede handles it.

## Testing
- bash -n and shellcheck: clean
- verified the pgrep/pkill pattern against a fixture process living at
  .../MCPProxy.app/Contents/MacOS/MCPProxy — matched, and cleared

* fix(tray): refresh the legacy staged core copy when it is provably stale

Related #957

FR-030, with the analysis it asks for recorded in the file header.

Findings: nothing in THIS tray resolves the staged copy at
~/Library/Application Support/mcpproxy/bin/mcpproxy ahead of the bundled
core — resolveBinary() checks the bundle first, so that branch is
reachable only for a build with no bundled core, where there is nothing
to shadow. The legacy Go tray does prefer it, but re-stages it from its
own bundle, so it tracks. The /usr/local/bin symlink points at the
bundled binary. A user's PATH is theirs.

The residual hazard is the file itself: a stale executable anything can
still run. So it is REFRESHED, never removed, and only when provable —
regular file (a symlink is deliberate wiring), answers `version -o json`
so it really is an mcpproxy binary, and that version is strictly older
by SemVer. Anything unprovable is a logged no-op.

## Changes
- `StagedCoreBinary.decide` (pure) + `refreshIfStale` (copy to a sibling
  temp, preserve the existing mode, rename over the target — a legacy
  tray may be executing that image, and a rename leaves it on its own
  inode)
- called detached at startup next to the symlink setup
- FR-030 note on resolveBinary()'s step 3 explaining the ordering

## Testing
- swift test --filter StagedCoreBinary: 13 tests, 0 failures — decision
  table (8) plus real file-system cases (5): stale copy swapped with its
  mode preserved and no temp left behind, current copy untouched,
  unidentifiable file neither refreshed nor deleted, absent path not
  created, symlink left pointing where it pointed

* fix(tray): keep the connection when a stale core cannot be stopped

Related #957

FR-002. The automatic branch tore the connection down and only then
discovered it could not identify the pid — leaving the user with an
error and no core, where a moment earlier they had a working (if old)
one. The unidentifiable-pid case is not a failure, it is the
"no safe stop mechanism" case the requirement answers with instructions.

## Changes
- check the pid's identity BEFORE dropping the connection; on refusal
  downgrade the offer to the pid-less instructions prompt and leave the
  connection alone. `stopCore` keeps its own re-check immediately before
  the signal (pids are recycled).

## Testing
- swift test --filter CoreSupersede: 21 tests, 0 failures (the refusal
  test now asserts .connected + a pid-less prompt)

* feat(core): report the effective update policy in /api/v1/info

Related #957

FR-015 asks for the update policy to be an *explicit* contract the tray can
read, "not inferred from missing data". Today the only signal is the presence
of the `update` object — and `Checker.GetVersionInfo()` returns nil BOTH when
checking is disabled and when no check has produced a result yet. A tray that
must decide "may I run a Sparkle feed check at all?" cannot tell those apart,
so it either nudges after the operator disabled updates or stays silent when
it should not.

## Changes
- `internal/updatecheck/policy.go`: `Policy{enabled, channel, nudges_suppressed}`
  plus `Checker.Policy()` (computed live, so a `SetConfig` hot-reload and both
  environment overrides are visible on the very next read) and
  `UnavailablePolicy()` for runtimes constructed without a checker.
- `update_policy` in `GET /api/v1/info`, always present with all three fields
  (no omitempty — an all-zero policy is exactly the disabled case that must not
  vanish). Wired `Runtime.UpdatePolicy` → `Server.UpdatePolicy` →
  `httpapi.ServerController`.
- `internal/contracts`, `cmd/generate-types` (+ regenerated
  `frontend/src/types/contracts.ts`), `make swagger`, `docs/api/rest-api.md`.

`enabled` governs AUTOMATIC checks only; a user-initiated "Check for Updates"
stays available, which is what FR-015 requires.

## Testing
- `internal/updatecheck/policy_test.go`: config/env precedence matrix, CI nudge
  suppression, unavailable-checker policy (CI pinned via t.Setenv).
- `internal/httpapi/info_update_policy_test.go`: field always present for four
  policies, including the all-zero one.
- go test -race ./internal/updatecheck/... ./internal/httpapi/... ./internal/contracts/... → ok
- golangci-lint v2 → 0 issues; server edition build + tests ok.

* feat(tray): wire the Sparkle one-click updater

Related #957

Sparkle 2.9.3 has been declared in Package.swift, dynamically linked, bundled
into Contents/Frameworks and code-signed by both build scripts since Spec 037 —
and never imported by a single Swift file. `UpdateService.checkWithSparkle()`
was a `NSClassFromString` stub that fell straight through to a raw GitHub API
call.

FIRST, the one unproven assumption in the decision report: `import Sparkle`
DOES resolve under plain `swift build` with no Xcode project. Verified by
compiling `#if !canImport(Sparkle) #error(...) #endif` against the package —
the error does not fire, so the whole framework path below is really compiled,
not silently `#if`-ed away.

## Changes

- `Services/FeedUpdater.swift` — `FeedUpdating` / `FeedUpdaterObserver`
  protocols plus the real `SparkleFeedUpdater`:
  * programmatic `SPUStandardUpdaterController(startingUpdater: false, …)` and
    an explicit `try updater.start()`. Deliberately NOT `startingUpdater:
    true`: that variant logs a misconfiguration and puts a "contact the
    developer" alert in front of the user a few seconds later — which is
    exactly what every build shipped before the CI appcast job would do, since
    Info.plist still carries `SPARKLE_PUBLIC_KEY_PLACEHOLDER`. A start failure
    now degrades to the browser path with a reason (FR-016/FR-017);
  * gentle reminders (FR-010): `supportsGentleScheduledUpdateReminders = true`,
    `standardUserDriverShouldHandleShowingScheduledUpdate` → false so a
    scheduled check never throws a window at a menu-bar user, and
    `standardUserDriverWillHandleShowingUpdate` feeds the published state;
  * `allowedChannelsForUpdater:` from the policy — empty set (Sparkle's
    "default channel only") for stable, `["beta"]` for RC (FR-014);
  * FR-012: the core is stopped in `updater(_:willInstallUpdate:)` — "called
    immediately before installing", i.e. the last moment the OLD bundle is
    still on disk. `shouldPostponeRelaunchForUpdate:` and
    `updaterWillRelaunchApplication:` both run AFTER the swap; the latter is
    kept as an idempotent second stop, the former is not used.
- `Services/ManagedCoreStop.swift` — the synchronous SIGTERM → bounded wait →
  SIGKILL ladder. Sparkle's pre-install hooks are synchronous main-thread
  callbacks, and `CoreProcessManager.shutdown()` hops to the main actor, so
  blocking on it would deadlock rather than stop anything. Phase 0's
  `CoreProcessIdentity` re-check still gates the signal.
- `Services/UpdatePolicy.swift` — FR-015 resolution: tray
  `MCPPROXY_DISABLE_AUTO_UPDATE`, CI suppression, and the core's new
  `update_policy`. A user-initiated "Check for Updates" bypasses all of it.
- `Services/UpdateInstallability.swift` — FR-016: translocation, read-only
  volume and unwritable parent, each with an explanation AND a fallback.
- `Services/UpdateMenuState.swift` — FR-017: one owner. Feed offer wins for the
  same or lower legacy version; equal versions dedupe; a legacy version the
  feed does not carry renders as browser guidance, never as a one-click action;
  a blocked app never gets a one-click item at all.
- `UpdateService.swift` rewritten around those pieces; `MCPProxyApp.swift`
  renders `updateService.menuEntries`, installs the pre-update core stop, and
  routes the launch/hourly checks through the policy-gated entry point (they
  used to call the user-initiated one).
- `API/Models.swift`, `Core/CoreProcessManager.swift`, `State/AppState.swift` —
  carry `update_policy` from `/api/v1/info` to the service on every connect.
- `scripts/build-swift-app.sh` — stamp `SUPublicEDKey` / `SUFeedURL` from
  `SPARKLE_PUBLIC_ED_KEY` / `SPARKLE_FEED_URL` when present; without them the
  bundle keeps the placeholder and one-click updates stay off, which is the
  correct failure direction.

## Testing
- `swift build` → Build complete (only the two pre-existing warnings).
- `cd native/macos/MCPProxy && swift test` → 911 tests, 1 failure — the known
  environmental `AppLifecycleTests.testTheSharedJournalNeverWritesToTheRealInstanceRootUnderTests`
  (this machine's live tray owns ~/.mcpproxy/tray-lifecycle.jsonl).
- New: UpdatePolicyTests 13 · UpdateMenuStateTests 14 · UpdateInstallabilityTests 6
  · ManagedCoreStopTests 8 · UpdateServiceFeedTests 16 — 0 failures. The last
  suite drives the REAL `AppController.rebuildMenu()`, so a renamed menu slot
  fails there.
- Delegate spelling verified against the Sparkle headers AND by the compiler:
  deliberately misspelling one method produces "nearly matches optional
  requirement"; the committed code produces zero such warnings.

## NOTES
- FR-018 (Homebrew cask `auto_updates true`) lives in the tap repo
  (smart-mcp-proxy/homebrew-mcpproxy) and is out of scope for this repository.
  It must be set before the first Sparkle-capable release, or `brew upgrade`
  will fight the in-app updater.
- No live upgrade QA: a real one-click run needs a notarized older build and a
  published appcast, neither of which exists yet.

* feat(tray): request the architecture-specific Sparkle feed

Related #957

A Sparkle appcast has no architecture selector, and the release pipeline builds
one app bundle per architecture — each carrying a per-arch core in
Contents/Resources/bin — both reporting the same CFBundleShortVersionString. A
single merged feed would therefore hand an Intel user the Apple-Silicon build,
and the failure would arrive as a crash after a "successful" update.

Until a universal enclosure exists (open decision #4 in the decision report),
the pipeline publishes one feed per architecture and the tray asks for its own.

## Changes
- `Services/SparkleFeedURL.swift` — pure rewrite of the configured `SUFeedURL`:
  only the exact default file name `appcast.xml` becomes `appcast-<arch>.xml`.
  Any other name is used verbatim, so an operator already serving a merged or
  universal feed is not second-guessed.
- `SparkleFeedUpdater.feedURLString(for:)` applies it, returning nil (= "use
  Info.plist") when the rewrite is a no-op.
- The file names are half of a contract with `.github/workflows/release.yml`,
  which generates exactly those names; the test file says so.

## Testing
- `SparkleFeedURLTests` — 5 tests, 0 failures (default rewrite per arch, nested
  paths, operator-supplied names left alone, query strings preserved).
- `swift build` → Build complete, no "nearly matches optional requirement"
  warning, i.e. the delegate method really binds.

* ci(release): publish a signed Sparkle enclosure and appcast

Related #957

FR-013/FR-014: the one-click updater needs two things this pipeline never
produced — a notarized, stapled, symlink-preserving archive of the .app, and a
signed update feed pointing at it. (The existing macOS assets do not qualify:
the bare DMG is signed but NOT notarized, and only the PKG-wrapping
`-installer.dmg` is notarized + stapled. Neither is an .app archive.)

## release.yml (stable)
- **Build Sparkle enclosure (macOS)**, inserted AFTER the existing notarization
  step and before keychain cleanup. The signing step order is untouched — the
  bundle was already signed nested-first; this step only archives, notarizes,
  staples and verifies it.
  * `ditto -c -k --sequesterRsrc --keepParent` — the only archiver Apple
    documents for this. `zip` drops symlinks and xattrs, which breaks the
    bundle's signature seal and surfaces on the user's machine as `Killed: 9`.
  * The .app is notarized and stapled SEPARATELY from the PKG: a stapled ticket
    lives inside the bundle it was stapled to, and the copy extracted from this
    zip is not the copy inside the PKG. Unstapled, the first launch after every
    update would wait on Apple — or fail offline.
  * Post-condition proven, not assumed: the archive is extracted again and run
    through `codesign --verify --deep --strict`, `stapler validate` and `spctl`.
  * Named `*.app.zip`, so the release job's existing "copy archives" glob picks
    it up and it lands in `checksums.txt` with everything else.
- **sparkle-appcast** job, `needs: [release]` — deliberately after publication,
  because `generate_appcast` bakes absolute download URLs into the feed and the
  first client to read it would otherwise 404. Runs Sparkle 2.9.3's
  `generate_appcast` (version pinned to Package.resolved) with the private key
  on **stdin** (`--ed-key-file -`), so it never touches disk or `ps`.
  Fails the job if a generated feed lacks `sparkle:edSignature`.
- ONE FEED PER ARCHITECTURE (`appcast-arm64.xml` / `appcast-amd64.xml`): an
  appcast has no arch selector and both bundles carry the same version, so a
  merged feed would offer Intel users the arm64 build. The tray-side half of
  this contract is `Services/SparkleFeedURL.swift`.
- `SPARKLE_PUBLIC_ED_KEY` / `SPARKLE_FEED_URL` passed to the Swift app build so
  `scripts/build-swift-app.sh` can stamp `SUPublicEDKey` / `SUFeedURL`.

## prerelease.yml (RC)
Same enclosure step and the same appcast job with `--channel beta`, gated on a
TAG (this workflow also runs on `next` pushes, which have no release to attach
a feed to). The job asserts the `<sparkle:channel>beta</sparkle:channel>` tag is
present — that tag is the only thing keeping stable users from being offered an
RC, since Sparkle offers a tagged item only to clients that request the channel.

## Fork / no-key behaviour
Every new step no-ops with a `::notice::` when `SPARKLE_ED_PRIVATE_KEY` is
absent, and both artifact uploads use `if-no-files-found: ignore`. A build
without `SPARKLE_ED_PUBLIC_KEY` keeps `SPARKLE_PUBLIC_KEY_PLACEHOLDER`, which
makes the updater refuse to start — no key, no one-click, never an unverified
install.

## Testing
- `python3 -c "import yaml; yaml.safe_load(...)"` on both files → parse.
- Every new `run:` block extracted and run through `bash -n` and
  `shellcheck -S warning` → clean.
- `actionlint` on both files reports nothing new (the pre-existing
  `matrix.edition` notices come from the commented-out server matrix rows).
- The workflows themselves cannot be executed here; the first real tag is the
  first true test. Riskiest untested lines: the `notarytool` submission of the
  .app zip and the `generate_appcast` invocation.

## NOTES
- FR-018 (`auto_updates true` on the Homebrew cask) lives in the tap repo,
  smart-mcp-proxy/homebrew-mcpproxy — out of scope here, and required before
  the first Sparkle-capable release.
- TODO (maintainer decision #1): the shipped Info.plist points at
  https://mcpproxy.app/appcast.xml, served by the WEBSITE repo, which this
  pipeline cannot publish to. Feeds are attached to the release and exported as
  the `sparkle-appcast` / `sparkle-appcast-beta` artifacts for it to consume.
- STILL MISSING from FR-014: the RC pipeline publishes no checksums.txt and no
  cosign bundle, so RC tarballs stay guidance-only for `mcpproxy update`.

* docs: document the auto-updater end to end

Related #957

New `docs/features/auto-update.md`: the per-channel behaviour matrix (CLI and
tray side by side), the one-click flow and its two independent verifications,
why the core is stopped before the swap rather than after, what happens when an
in-place update is impossible, the FR-017 "one item, one owner" rule, every kill
switch, the `update_policy` contract, how the RC/beta channel keeps stable users
away from prereleases, and the release infrastructure (enclosure, per-arch
feeds, the three signing key/variable names, and the fork/no-key behaviour).

Linked from `docs/configuration.md`'s env-var table — which also gains the `CI`
row that was previously only documented for the core — and from
`docs/features/version-updates.md`, which covers *noticing* an update rather
than applying one.

The unresolved feed-hosting decision is called out in an admonition rather than
buried: the shipped Info.plist points at https://mcpproxy.app/appcast.xml, which
only the website repo can serve, and the interim GitHub
`releases/latest/download/...` URL works for stable but structurally cannot
serve the beta channel.

* docs: list Auto-Update in the sidebar and the second env-var surface

Related #957

Two follow-ups to the auto-update doc:

- `website/sidebars.js` is an EXPLICIT sidebar, not filesystem-generated, so a
  new page under docs/features/ is built and routed but invisible in navigation
  until it is listed. Added next to Version Updates.
- `docs/configuration/environment-variables.md` is a second env-var surface
  (alongside the table in `docs/configuration.md`); its Auto-Update section
  never mentioned the CI rule and had no pointer to the updater itself. Both
  surfaces now say the same thing and link to the same page.

* fix(ci): give the sparkle-appcast jobs a repo for gh to upload to

Related #957

Both new `sparkle-appcast` jobs deliberately skip `actions/checkout` — they
only need the enclosure artifacts. But `gh` resolves the target repository
from the current directory's git remote, and it does NOT read
`GITHUB_REPOSITORY`; the only env var it consults is `GH_REPO`. So the final
`gh release upload "${GITHUB_REF_NAME}" appcast-out/*.xml` would have failed
with "could not determine what repo to use" on the first real tag — after
generating and signing the feeds, so the failure would land at the very last
step of a release.

Every other `gh release upload` in both workflows lives in a job that ran
`actions/checkout`, which is why this is the only pair that needed it.

## Changes
- `GH_REPO: ${{ github.repository }}` in the appcast step's env, in
  release.yml and prerelease.yml, with a note explaining why a checkout is
  not the fix.

## Testing
- Both workflows re-parse with `yaml.safe_load`; asserted the resolved env
  block of `jobs.sparkle-appcast` now carries GH_REPO in each file.

* feat(cli): warn when --self asserts ownership of an unknown install

Related #957

FR-020 allows `--self` to turn the guidance-only `unknown` channel into a
self-update, but attaches "with a clear warning" to that override — and the
override shipped without one. The requirement is not cosmetic: `unknown` is
precisely the set of installs mcpproxy could NOT identify, which includes
AUR, MacPorts and Nix-like layouts that are writable and still owned by a
package manager whose bookkeeping the swap silently invalidates. A user who
types --self after reading the guidance line deserves to be told what they
just claimed.

A positively identified tarball install stays silent — there is nothing
uncertain about it to warn over — and the warning goes to stderr so `-o json`
stays machine-parseable.

## Changes
- `updateRunner.run`: emit the warning on the self-update branch only when
  the effective channel is `unknown`.

## Testing
- New `TestUpdateCommand_SelfOverrideWarnsOnUnknownChannel`: 3 cases
  (unknown+--self warns, tarball silent, tarball+--self silent), each also
  asserting the self-update branch really ran and that stdout's JSON report
  never contains the warning.
- `go test -race ./cmd/mcpproxy/... ./internal/updatecheck/... ./internal/httpapi/...` → 3/3 packages ok.

* docs(update): correct the tarball channel comments and the dev-bundle plist

Related #957

Two leftovers from the tarball channel becoming command-carrying (FR-020).

`UpdateBanner.vue` still listed `tarball` among the channels with no safe
update command, in both template comments and the guidance switch. The
rendered output was already correct — every branch keys off the presence of
`update_command`, never the channel name — so this is comment-only and
changes no markup, no class, and no computed result.

`build-macos-tray.sh`'s from-scratch Info.plist (the branch that runs only
when the source Info.plist is missing) emitted `SUFeedURL` with no
`SUPublicEDKey`. A feed URL with no pinned key is the one bundle shape
FR-011 must never produce; the checked-in plist and `build-swift-app.sh`
both carry the placeholder, and this branch should not be the one that
diverges.

## Changes
- `frontend/src/components/UpdateBanner.vue`: comments only.
- `native/macos/MCPProxy/scripts/build-macos-tray.sh`: add the
  `SUPublicEDKey` placeholder to the fallback plist.

## Testing
- `bash -n` + `shellcheck -S warning` on build-macos-tray.sh: clean apart
  from the pre-existing SC2155 at line 25.
- Scanned every HTML comment in UpdateBanner.vue for an embedded `--`
  (which would break the comment) — none.

* fix(cli): make the self-update swap crash-recoverable and its version check exact

Related #957

Two defects in the mechanical half of `mcpproxy update --self` (FR-021).

The pair of renames that swaps the binary is not atomic, and the retry path
made an interrupted swap unrecoverable: it removed a leftover `<target>.old`
before ever looking at the target, so a crash in the window where the target
path is empty and `.old` holds the only copy of the binary was turned into a
destroyed install by the next attempt.

The post-swap probe matched the reported version as a substring, so a binary
announcing 0.54.10 satisfied a request for 0.54.1 — the wrong release would
have passed verification and had its predecessor deleted.

## Changes
- `restoreInterruptedSwap` runs first: target missing + backup present moves
  the backup back; target missing with no backup is a refusal, not a silent
  install over nothing. A leftover backup is only removed once the target is
  known to be present.
- `reportsVersion` compares whole whitespace-delimited tokens (tolerating a
  `v` prefix and surrounding punctuation) instead of a substring.

## Testing
- go test ./cmd/mcpproxy/ — interrupted-swap recovery, interrupted swap with
  no staged binary, missing target and backup, 0.54.10 vs 0.54.1

* fix(tray): honour SemVer §9 and compare unbounded numeric identifiers

Related #957

Two gaps in the comparison FR-006 makes every supersede and update decision
depend on.

Leading zeros in a numeric prerelease identifier were accepted, so "rc.01" and
"rc.1" were two spellings of one version and one of the two orderings they
produced had to be wrong. §9 forbids them; FR-006 says a malformed version is
a no-decision, not a guess.

Numeric identifiers were coerced through `Int`, which returns nil past Int64.
Two identifiers that both overflowed fell through to the alphanumeric branch
and were compared as ASCII, ordering "10000000000000000000000" below
"9999999999999999999999" — a downgrade offered as an update.

## Changes
- Prerelease identifiers are validated with `numericMayHaveLeadingZeros:
  false`; build metadata keeps the §10 exemption
- `compareNumericIdentifiers` compares digit strings by length then
  lexicographically, with no upper bound

## Testing
- swift test — leading-zero rejection, a lone "0" still valid, build metadata
  unaffected, 22- and 23-digit identifiers both directions

* fix(tray): let exactly one source own the update menu item

Related #957

FR-017 allows one update item at a time. When the feed offered a version and
the legacy GitHub check advertised a NEWER one, the resolver rendered both: a
one-click item and a browser item, side by side, asking the user to choose
between an install the tray can perform and a download it cannot verify.

The feed now owns the slot whenever it has an offer, whatever the legacy check
found. What the feed offers is real and installable in one click, and the next
scheduled check picks up the newer version. The legacy result is only rendered
when the feed has nothing.

## Changes
- Collapse the two-source switch to "feed if present, else legacy guidance"
- Drop the now-unreachable comparison branches

## Testing
- swift test — the full feed × legacy × blocked matrix asserts at most one
  offer and that the feed owns it

* fix(tray): make the update pipeline refuse to act on what it has not confirmed

Related #957

Five interlocking defects in the tray half of the updater. They share one
shape: something was assumed — a policy, a feed name, a dead process — where
nothing had been established.

**The launch check ran before the policy arrived.** `/api/v1/info` carries the
core version and the update policy, and Combine delivers `@Published`
subscribers from `willSet` — so assigning `version` first ran the launch check
under the previous (permissive-by-default) policy, checking for updates for a
user who had switched them off. The default is now restrictive and the two
assignments are ordered. A core that predates 092 is a different case and stays
permissive: the tray stamps `CoreUpdatePolicy.legacyDefault` at the point it has
actually talked to one, which is what keeps "an old core said nothing" apart
from "we have not asked yet" (FR-015).

**RC clients read the stable feed.** `prerelease.yml` publishes
`appcast-beta-<arch>.xml`; the rewrite only ever produced `appcast-<arch>.xml`.
An RC user was pointed at a feed that by FR-014's design never carries an RC,
so the beta channel could not offer anything at all.

**SIGKILL was reported as success.** `ManagedCoreStop` returned `.killed` the
instant it sent the signal. Sparkle used that answer to decide the bundle could
be replaced, so a core that outlived SIGKILL kept serving from a deleted inode
— issue #957 exactly. The stop now confirms the exit, and a stop that ends in a
question mark POSTPONES the installation through
`shouldPostponeRelaunchForUpdate:untilInvokingBlock:` (verified against Sparkle
2.9.3: it runs at the top of `installWithToolAndRelaunch:`, before the
installer is contacted) rather than being logged and ignored.

**SIGKILL trusted a five-second-old identity check.** Both kill ladders proved
the pid was an mcpproxy process, waited out the SIGTERM grace period, then
killed whatever held the pid by then. The identity is re-proven immediately
before the signal.

**One click was two clicks with a download in between.** `automatically-
DownloadsUpdates` now follows the same switch as scheduled checks, so the bytes
are verified and on disk before the menu item is touched. The residual Sparkle
confirmation cannot be removed through the public API — `SPUUpdater` has no
"install what you are holding" method — so the honest count is one click on our
item plus one on Sparkle's install prompt, and that is written down where the
next reader will look.

## Changes
- `EffectiveUpdatePolicy.awaitingCore`; `CoreUpdatePolicy.legacyDefault`
- `coreUpdatePolicy` assigned before `version` in `connectToCore`
- `SparkleFeedURL.archSpecific(_:arch:channel:)` + `fileName(channel:arch:)`
- `ManagedCoreStopOutcome.failed` and `.coreIsDown`; bounded post-SIGKILL wait
- `feedUpdaterWillInstallUpdate()` returns whether the core is down; Sparkle
  postpones when it is not
- Identity re-check before SIGKILL in `ManagedCoreStop` and `stopCore`
- `automaticallyDownloadsUpdates`, `updateIsReadyToInstall`

## Testing
- swift test — 87 tests over the policy precedence, both channels × both
  arches, the post-SIGKILL confirmation, pid recycling across the grace
  period, and the install veto

* fix(packaging): quit only the installing user's tray, not everybody's

Related #957

postinstall runs as root, where `pgrep -f` and `pkill -f` match every user's
processes. Installing an upgrade for one account therefore quit — and, two
rungs down the ladder, SIGKILLed — the MCPProxy tray of every logged-in user
on the machine, including sessions nobody was upgrading.

## Changes
- Resolve `-U <console uid>` once (the same uid `run_as_user` already hops to)
  and pass it to every pgrep/pkill; empty when there is no real user, which is
  the CI-imaging case where there is nobody else's session to protect
- A plain string rather than an array: macOS bash 3.2 errors on an empty array
  expansion under `set -u`

## Testing
- bash -n packaging/macos/postinstall.sh

* ci(prerelease): publish signed checksums for RC artifacts

Related #957

FR-014 requires RC builds to be covered by a signed checksum manifest, and the
prerelease pipeline published neither checksums.txt nor a cosign bundle. That
made every RC uninstallable by `mcpproxy update`: the command refuses an
artifact it cannot verify, so a user who opted into the prerelease channel had
no update path at all — the CLI would find the RC and then decline it.

## Changes
- `Generate checksums` in the prerelease `release` job, mirroring release.yml
  including the self-reference guard
- `Sign checksums (keyless)` + bundle upload, guarded on an OIDC token being
  available so a fork run publishes unsigned rather than failing
- `id-token: write` on the job (release.yml grants it workflow-wide)
- `cosignIdentityRegexp` accepts prerelease.yml as well as release.yml; both
  are pinned to `@refs/tags/v`, so this widens the trusted set by nothing a tag
  push could not already do
- Corrected the two comments that described the missing artifacts as a
  standing gap

## Testing
- python3 -c 'yaml.safe_load(...)' on both workflow files
- go build ./... ; go test ./cmd/mcpproxy/...

* docs(update): spell out what still has to happen before one-click works

Related #957

The feed URL baked into every bundle (https://mcpproxy.app/appcast.xml) is
served by nobody: the release pipeline attaches the feeds to the GitHub release
and exports them as workflow artifacts, and the website repository would have
to publish them. That is a maintainer decision, not something this branch can
make — but it was recorded only as a warning box halfway down the page, which
is not enough for someone trying to work out why nobody gets one-click updates.

An explicit activation checklist replaces it, naming the exact files the
website repo must serve, at the exact URLs the tray derives, and what the
serving side must not do to them.

The graceful side was already correct and is now pinned by tests: a feed 404
reads as "the feed has nothing" and hands the menu to the browser-download
path, rather than surfacing as an error that eats the offer.

## Changes
- "Activation checklist" section: five steps, the artifact→URL table, the
  three serving constraints, and the interim stable-only GitHub URL
- Banner at the top pointing at it
- Brought the page in line with this round's behaviour changes: honest
  two-click count, the postpone-on-failed-stop rule, one item always, the
  per-channel feed table, and the restrictive pre-policy default
- Troubleshooting entries for the feed 404 and the postponed install

## Testing
- swift test — feed failure falls back to browser guidance, and a failure
  after a withdrawal leaves no stranded one-click item

* fix(cli): tell an unverified swap apart from a finished one

Related #957

The recovery added in 20caffafa closed one crash window and left the other
open. A crash AFTER the staged->target rename but BEFORE verification leaves an
unverified binary at the target and the last known-good one at .old — and on
disk that is indistinguishable from a completed update. Recovery saw a target
and returned; the swap then deleted .old, and if that attempt's verification
failed, the rollback restored the unverified binary. The known-good one was
already gone.

Nothing about the file contents can settle it, so the swap now records that it
is in progress. A sentinel written next to the target before the first rename
and removed only after verification passes makes the two states tell themselves
apart, and no path removes the backup while it exists.

With the sentinel present, target and backup between them say how far the
interrupted swap got:

  neither         -> nothing to recover from; refuse
  backup only     -> crashed between the renames; restore it
  target only     -> crashed before the first rename; the target is original
  target + backup -> crashed after the second; the target was never verified

Only the last needs a judgement, and it is made by running the caller's
verification against what is at the target. It proves itself or the known-good
binary goes back. With no verifier available, the known-good binary wins.

## Changes
- `swapSentinelSuffix` + `writeSwapSentinel` (fsynced content; the directory
  entry is not, which is the same guarantee the renames already had)
- `recoverInterruptedSwap` replaces `restoreInterruptedSwap`, covering all four
  states plus the no-sentinel case left by a build that predates it
- `restoreKnownGood` — os.Rename over the target is what discards the
  unverified binary
- The sentinel is removed on every path the function returns by, so only a
  crash leaves one

## Testing
- go test -race ./cmd/mcpproxy/ — verified interrupted swap cleans up, an
  unverifiable one is replaced, a retry that cannot install ends on the
  known-good binary, no verifier prefers the known-good, crash-before-first-
  rename is a no-op, and the sentinel is present while the new binary is
  unverified

* fix(tray): never arm the install-on-quit the core stop cannot veto

Related #957

Enabling automaticallyDownloadsUpdates for the pre-download in 6914bb664 also
switched Sparkle from SPUScheduledUpdateDriver to SPUAutomaticUpdateDriver —
the driver is chosen on that flag alone (SPUUpdater.m:621) — and that driver
arms a silent install-on-quit. It hands the update to the external installer
tool, which replaces the bundle once this process exits, on a path that never
calls installWithToolAndRelaunch: and therefore never reaches the postpone hook
that refuses an unconfirmed core stop. The fallback the on-quit path does reach
is `void`; it called the stop and dropped the answer.

`willInstallUpdateOnQuit:` is NOT a veto, so intercepting it does not fix this.
Its own documentation: "In either case Sparkle will always attempt to install
the update when the app terminates." Nor does the terminate path make it safe —
applicationWillTerminate sends the core a SIGTERM and does not wait, so the
installer can still run against a live core.

What is provably safe is not arming it: with automaticallyDownloadsUpdates
false, SPUAutomaticUpdateDriver is never constructed and every install goes
through the postpone hook, which can refuse. The cost is that FR-010's click
downloads before it installs — a slower click, in exchange for an install that
can always be called off.

## Changes
- `EffectiveUpdatePolicy.automaticDownloadsAllowed` — always false, given a
  name and a test because SparkleFeedUpdater cannot be instantiated outside a
  real .app bundle. Assigned unconditionally: the flag is backed by a user
  default Sparkle's own permission prompt can write, so leaving it alone is not
  the same as it being off
- `willInstallUpdateOnQuit:` implemented as a tripwire — it cannot refuse, so
  it logs and surfaces the situation instead of pretending to have handled it
- The two `void` hooks report a failed stop instead of discarding it
- Docs: honest click count and why the pre-download is refused

## Testing
- swift test — every policy (permissive, awaiting-core, legacy, rc, disabled,
  CI, kill switch) refuses pre-downloading

* docs(update): link Prerelease Builds via GitHub — the doc is not on the website

Related #957

The docs site's docusaurus include-whitelist serves only configuration/**,
development/**, etc.; root-level docs/prerelease-builds.md has no site route,
so /prerelease-builds broke the site build (onBrokenLinks: throw).

* fix(cli): serialize self-update per target with an OS-level advisory lock

Related #957

Two concurrent 'mcpproxy update' runs could read each other's mid-swap
sentinel state, misclassify it, and delete the only known-good backup.
The whole recover-and-swap sequence now runs under an exclusive
non-blocking flock (LockFileEx on Windows) on <target>.update-lock; the
second invocation fails fast with 'another update is already in
progress'. The lock file is never unlinked (unlink+relock races two
holders onto different inodes); the kernel drops the lock with the
process, so a crash cannot wedge future updates.

* fix(cli): key the update lock on the binary, not on the name we resolved

Related #957

The per-target lock did not serialize what it was supposed to. Its key was the
resolved executable path, and on Linux os.Executable() reads /proc/self/exe,
which names the running INODE rather than the path it was launched from. A
second `mcpproxy update` that resolves its own path AFTER the first has renamed
mcpproxy to mcpproxy.old therefore sees itself as mcpproxy.old: it locks
mcpproxy.old.update-lock, contends with nobody, and proceeds to swap
mcpproxy.old — moving the first process's only known-good backup aside. Two
"exclusive" holders on two different files. macOS and Windows report the launch
path and do not alias this way, so the fix is pure path arithmetic with no
/proc reads and behaves identically everywhere.

Both layers are addressed, because they answer different questions.

The LOCK KEY is canonicalized: mcpproxy.old, mcpproxy.updating,
mcpproxy.update-lock and the staged .mcpproxy.new-<pid> all resolve to
mcpproxy, so however each process spells the path they contend on one file.
Mapping an unrelated user file named foo.old onto foo's lock costs nothing
worse than two updates taking turns.

Deciding which file to REPLACE gets the opposite treatment: it refuses. Two
things produce a target called mcpproxy.old — a concurrent update that just
renamed the binary aside, where we must touch nothing, and a user who kept a
copy under that name and ran it, where rewriting mcpproxy instead would
silently update the wrong binary. Nothing on disk reliably separates them and
one of the answers is destructive, so neither is guessed at; the error names
both readings and says what to do about each.

## Changes
- `canonicalUpdateTarget` / `stripUpdateArtifactSuffix`, applied repeatedly
  (a swap of an aliased path yields names like mcpproxy.old.old) and leaving
  non-aliased paths untouched rather than silently Clean-ing them
- `acquireUpdateLock` keys on the canonical path
- `refuseAliasedUpdateTarget` gates `applyNewBinary`, and `selfUpdate` too so a
  concurrent update is caught before ~90 MB is downloaded on its behalf
- `backupSuffix` named, so the suffix list cannot drift from the swap

## Testing
- go test -race ./cmd/mcpproxy/ — canonicalization table (incl. directories,
  double suffixes, staged names, near-misses), x and x.old contending on one
  lock with no aliased lock file created, and an aliased swap refused with the
  other update's backup left untouched
- go build ./... ; GOOS=linux and GOOS=windows builds of ./cmd/mcpproxy

* ci(release): auto-publish the Sparkle feeds to mcpproxy.app

Related #957

After uploading the signed appcasts as release assets, release.yml and
prerelease.yml dispatch publish-appcast at the website repo (same
MARKETING_SITE_DISPATCH_TOKEN pattern as the marketing version bump);
its receiving workflow downloads the feeds from the public release,
verifies signatures, and commits them into public/ for Cloudflare
Pages. Non-blocking and skipped when no appcast was generated; manual
backfill stays possible via the site workflow's workflow_dispatch.
Website side: smart-mcp-proxy/mcpproxy.app-website#4.

* docs(092): rollout & first-test plan — failure modes, RC dress rehearsal, N+1 canary

Related #957

Detection failures degrade to the legacy browser nudge (structurally
never 'stuck forever'); install failures are the class to guard. First
real test is an rc.1->rc.2 one-click on the beta channel with the full
production pipeline before any stable ships; the concurrency release
follows within days as the first OTA payload to minimize the exposure
window if release N's updater is broken.
Dumbris added a commit that referenced this pull request Sep 16, 2026
…oped resolver never lists, path-free sibling refusals (FR-012)

Critique 1/2 #1 (MUST): GET /api/v1/code/scripts answered any agent token
with every stored name, host path and the scripts directory — the exact
enumeration the missing-script refusal now withholds, and the published
code_execution description claimed the listing was administrator-only.
handleListScripts opens with requireAdminRead (the same !IsAdmin predicate
the MCP refusal keys on): admin key / tray socket / nil context pass, every
agent token gets 403 with a body naming nothing. @failure 403 added and
oas/{swagger.yaml,docs.go} regenerated (diff limited to that route).

Critique 1 #2/#3 (SHOULD): the scoped refusal was built by resolving with
the administrator form (List → one lstat per stored script) and stripping
the result afterwards, and the ambiguous / unusable / unreadable-directory
refusals still carried the host paths and the raw OS error. codescripts
gains ResolveScoped: a shared resolve(…, disclose) body where the scoped
not-found error is constructed without touching the directory
(listForNotFound seam + witness test proves 0 listings) and AmbiguousError
/ InvalidError get an Undisclosed form (name + reason, no Path/Paths/
Detail) with typed identity preserved for the REST classifier. The server
picks Resolve / ResolveScoped by auth.IsScopedCaller; the post-hoc copy
and the debug log of the withheld count are gone (#6).

Critique 1 #4 / 2 #3: the anonymous /mcp caller under require_mcp_auth=false
is administrator-shaped by design (spec FR-002); code unchanged, docs now
say who counts as an administrator and how to close that door.

Critique 1 #5: the nested-b positive control now has a real hidden b
(counting upstream): a-only refusal byte-equal to the nonexistent-b run,
zero upstream calls, admin control reaches b.

Critique 2 #2: agent-tokens.md invariant carries a rollout-status note
(lands surface by surface; present-tense rules are the enforced ones).
#4: REST classification rows for the three non-disclosing forms. #5: stale
comments in code_exec.go / code_cmd.go. #6: T064/T067 wording. #7: reuse
jsonRPCHandler and a configure-hook fixture. #8: errors.As through a %w
wrapper instead of on the bare pointer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dumbris added a commit that referenced this pull request Sep 17, 2026
…oped resolver never lists, path-free sibling refusals (FR-012)

Critique 1/2 #1 (MUST): GET /api/v1/code/scripts answered any agent token
with every stored name, host path and the scripts directory — the exact
enumeration the missing-script refusal now withholds, and the published
code_execution description claimed the listing was administrator-only.
handleListScripts opens with requireAdminRead (the same !IsAdmin predicate
the MCP refusal keys on): admin key / tray socket / nil context pass, every
agent token gets 403 with a body naming nothing. @failure 403 added and
oas/{swagger.yaml,docs.go} regenerated (diff limited to that route).

Critique 1 #2/#3 (SHOULD): the scoped refusal was built by resolving with
the administrator form (List → one lstat per stored script) and stripping
the result afterwards, and the ambiguous / unusable / unreadable-directory
refusals still carried the host paths and the raw OS error. codescripts
gains ResolveScoped: a shared resolve(…, disclose) body where the scoped
not-found error is constructed without touching the directory
(listForNotFound seam + witness test proves 0 listings) and AmbiguousError
/ InvalidError get an Undisclosed form (name + reason, no Path/Paths/
Detail) with typed identity preserved for the REST classifier. The server
picks Resolve / ResolveScoped by auth.IsScopedCaller; the post-hoc copy
and the debug log of the withheld count are gone (#6).

Critique 1 #4 / 2 #3: the anonymous /mcp caller under require_mcp_auth=false
is administrator-shaped by design (spec FR-002); code unchanged, docs now
say who counts as an administrator and how to close that door.

Critique 1 #5: the nested-b positive control now has a real hidden b
(counting upstream): a-only refusal byte-equal to the nonexistent-b run,
zero upstream calls, admin control reaches b.

Critique 2 #2: agent-tokens.md invariant carries a rollout-status note
(lands surface by surface; present-tense rules are the enforced ones).
#4: REST classification rows for the three non-disclosing forms. #5: stale
comments in code_exec.go / code_cmd.go. #6: T064/T067 wording. #7: reuse
jsonRPCHandler and a configure-hook fixture. #8: errors.As through a %w
wrapper instead of on the bare pointer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dumbris added a commit that referenced this pull request Sep 18, 2026
… PR H0, FR-012) (#1285)

* test(scope): Spec 105 PR H0 red phase — FR-012 stored-script enumeration is admin-only

Failing tests for gaps FR01x-G1 and FR01x-G2 (tasks T062/T063), plus the
spec.md:116 positive controls as regression pins:

- TestCodeExecution_ScriptNotFound_AgentTokenNonDisclosing (G1, RED): an
  agent token ["*"] requesting a missing stored script with alpha-SENTINEL.js
  present gets an error naming neither the sentinel, the other script nor
  "Available scripts (N)", byte-equal to the same caller's refusal against
  the empty directory; proven at the handler and through the /mcp and
  /mcp/code JSON-RPC seams. The administrator control keeps the enumeration
  (TestCodeExecution_ScriptNotFoundListsAvailable stays as the nil-auth
  admin control).
- TestCodeExecutionDescriptions_EnumerationIsAdminOnly (G2, RED): the live
  code_execution.description / script.description no longer advertise
  discovery-by-failed-call and name the administrator-only rule; the three
  goldens differ from the new frozen testdata/toolslist_goldens/pre105/
  baseline in exactly those two strings of the code_execution entry and in
  nothing else (tool set and every other entry byte-equal).
- TestCodeExecution_StoredScript_ScopedPositiveControls (pin, green): an
  a-only token runs a constant-returning stored script and gets the
  constant; a stored script calling b is refused at the nested call with
  ACCESS_DENIED.
- TestScopedInitialize_PublishesCustomInstructions (pin, green): a scoped
  initialize on /mcp and the direct server publishes custom instructions
  mentioning b:private_search verbatim, identical for admin — documented
  operator-published content.

pre105/ is a byte copy of the current goldens (not a regeneration);
.gitattributes pins LF for the frozen sub-directories.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(scope): Spec 105 PR H0 — stored-script enumeration is administrator-only (FR-012)

Green phase for tasks T064-T067 (gaps FR01x-G1..G3), closing the red tests
committed in bcf691b.

- Caller-kind branch (T064): resolveCodeExecutionSource passes a stored-script
  resolution failure through scopeStoredScriptRefusal. For a scoped caller
  (auth.IsScopedCaller — the caller KIND decides, never AllowedServers) the
  Spec 097 FR-004 NotFoundError is swapped for its NonDisclosing() copy, whose
  text carries only the requested name: no other script names, no count, no
  directory path, and byte-equal for an empty and a populated directory. The
  typed identity survives, so the REST classifier still answers 404
  SCRIPT_NOT_FOUND. An absent auth context (in-process caller) and
  administrators keep today's "Available scripts (N): ..." enumeration
  (SC-005 named exception; TestCodeExecution_ScriptNotFoundListsAvailable
  kept as the admin control).
- Definition text (T065): code_execution.description and the script
  parameter description no longer advertise discovery-by-failed-call and say
  enumeration is administrator-only / agent-token callers must already know
  the name. The three tools-list goldens were regenerated with
  MCPPROXY_WRITE_TOOLSLIST_GOLDENS=testdata/toolslist_goldens (the ONE narrow
  golden exception of Spec 105); TestCodeExecutionDescriptions_
  EnumerationIsAdminOnly proves the delta against the frozen pre105/ copy is
  limited to exactly those two strings of the code_execution entry.
- Docs (T066): docs/code_execution/{overview,cookbook,troubleshooting,
  api-reference}.md state the administrator-only rule and show the
  agent-token refusal; docs/features/agent-tokens.md gains the "What a scoped
  token cannot learn" section (invariant sentence, covered-surface list,
  retained-effects list, custom-instructions/stored-scripts secrets warning);
  docs/configuration.md warns that `instructions` is published to every
  caller. Links use docs.mcpproxy.app URLs.
- codescripts: TestNotFoundError_NonDisclosing pins the stripped form.
- tasks.md T062-T067 ticked; ROADMAP.md regenerated by scripts/gen-roadmap.py.

Verification: go test -race -count=1 (with the internal/server skip list)
./internal/server/... ./internal/codescripts/... ./internal/httpapi/... green;
go build ./cmd/mcpproxy and -tags server clean; gofmt/goimports/vet clean on
touched files; git diff --stat origin/main -- internal/server/testdata shows
only the three regenerated goldens plus the frozen pre105/ baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scope): PR H0 critique round 1 — gate the REST script listing, scoped resolver never lists, path-free sibling refusals (FR-012)

Critique 1/2 #1 (MUST): GET /api/v1/code/scripts answered any agent token
with every stored name, host path and the scripts directory — the exact
enumeration the missing-script refusal now withholds, and the published
code_execution description claimed the listing was administrator-only.
handleListScripts opens with requireAdminRead (the same !IsAdmin predicate
the MCP refusal keys on): admin key / tray socket / nil context pass, every
agent token gets 403 with a body naming nothing. @failure 403 added and
oas/{swagger.yaml,docs.go} regenerated (diff limited to that route).

Critique 1 #2/#3 (SHOULD): the scoped refusal was built by resolving with
the administrator form (List → one lstat per stored script) and stripping
the result afterwards, and the ambiguous / unusable / unreadable-directory
refusals still carried the host paths and the raw OS error. codescripts
gains ResolveScoped: a shared resolve(…, disclose) body where the scoped
not-found error is constructed without touching the directory
(listForNotFound seam + witness test proves 0 listings) and AmbiguousError
/ InvalidError get an Undisclosed form (name + reason, no Path/Paths/
Detail) with typed identity preserved for the REST classifier. The server
picks Resolve / ResolveScoped by auth.IsScopedCaller; the post-hoc copy
and the debug log of the withheld count are gone (#6).

Critique 1 #4 / 2 #3: the anonymous /mcp caller under require_mcp_auth=false
is administrator-shaped by design (spec FR-002); code unchanged, docs now
say who counts as an administrator and how to close that door.

Critique 1 #5: the nested-b positive control now has a real hidden b
(counting upstream): a-only refusal byte-equal to the nonexistent-b run,
zero upstream calls, admin control reaches b.

Critique 2 #2: agent-tokens.md invariant carries a rollout-status note
(lands surface by surface; present-tense rules are the enforced ones).
#4: REST classification rows for the three non-disclosing forms. #5: stale
comments in code_exec.go / code_cmd.go. #6: T064/T067 wording. #7: reuse
jsonRPCHandler and a configure-hook fixture. #8: errors.As through a %w
wrapper instead of on the bare pointer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scope): PR H0 codex round 1 — scoped script resolution never reads the directory (FR-012)

candidatesFor still os.ReadDir'd and walked every entry on the way to the
count-independent not-found refusal, so a scoped miss cost ~11 µs in an
empty scripts directory and ~4.7 ms with 10k entries: a timing-class oracle
the non-disclosing-refusal definition forbids. It now Lstat's the two
constructed candidate paths and never lists; the byte-exact name rule the
listing existed for on case-insensitive volumes is kept by a single-entry
entryName primitive (darwin O_SYMLINK + F_GETPATH, Windows FindFirstFile,
other Unix trusts the exact-name Lstat).

Tests: readDir/lstat seam counters — a scoped hit or miss reads zero
directories (the administrator miss exactly one), and an empty vs a
10 000-entry directory perform identical filesystem calls. Existing
case-exactness tests pin the primitive (mutation-checked on APFS).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(scope): PR H0 — make the FR-012 tests portable to Windows

The administrator-listing control compared the raw JSON body with the OS
path (backslashes are escaped on Windows); decode data.dir instead. The
unreadable-directory cell relies on chmod 0, which Windows ignores; skip
it there like the root case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scope): PR H0 codex round 2 — administrator Resolve keeps its directory-based candidate decision (SC-005)

Round 1 routed both Resolve and ResolveScoped through the constant-cost
Lstat probe, which changed administrator execution: a scripts directory
that is searchable but not listable (0111) refused every administrator
run before Spec 105 (os.ReadDir → permission denied) and executed the
script after round 1.

candidatesFor is restored to the origin/main directory-read
implementation and remains the administrator resolver's decision; the
path probe becomes probeCandidates and is used by ResolveScoped alone
(FR-012 timing class). Control test
TestResolve_SearchableUnreadableDirectoryIsStillUnreadableForAdmins
(skipped on Windows and as root) pins the pre-105 refusal; the two
case-exactness tests now run over both resolvers so the scoped probe's
fold guard stays covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scope): PR H0 codex round 3 — scoped resolution fails closed on a Linux case-folding mount (FR-012)

The Linux/BSD entryName returned the caller-constructed basename, so the
scoped probe's exact-spelling check could never fire there: on ext4
casefold, vfat, or a bind mount from a case-insensitive host (a Docker
Desktop mount of ~/.mcpproxy into mcpproxy-server), Lstat("backdoor.js")
finds a stored `backdoor.JS` that List and the administrator's Resolve
reject, and an agent-token `script:"backdoor"` executed it. Reproduced on
real Linux in Docker against an APFS bind mount.

Linux has no single-entry call that reports how an entry is spelled on
disk — readlink of /proc/self/fd/N echoes the spelling that was looked
up — and the only exact answer is the directory listing the scoped
resolver must not perform. So the fold is proven instead: foldsCase
Lstats the same name with every ASCII letter's case swapped (one extra
constant-cost probe; names and extensions are ASCII), and the same entry
answering under both spellings means the lookup folds. The Linux/BSD
entryName then reports errSpellingUnverifiable and probeCandidates
refuses the candidate with the ordinary non-disclosing not-found — fail
closed rather than execute a file no discovery surface reports. darwin
(F_GETPATH) and Windows (FindFirstFile) keep their exact-spelling checks;
the administrator's directory-based decision is untouched (SC-005).

Tests: TestFoldsCase (every platform) and the Linux-tagged
TestResolveScoped_FailsClosedOnAFoldingDirectory /
TestEntryName_ExactOnACaseSensitiveLookup, simulating the folding lookup
through the lstat seam; the case-distinct test gains the fail-closed
scoped branch and three scoped tests skip on such a mount. Verified in
Docker natively with -race and with TMPDIR on the folding bind mount.
Operator note in docs/code_execution/troubleshooting.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scope): PR H0 review round 4 — an exactly named script runs on a Linux case-folding mount; the fold pays one listing (FR-012)

Round 3 made the Linux/BSD entryName report the spelling as unverifiable
whenever the case-swapped name resolved to the same file, and the scoped
resolver refused the candidate. That refused correctly named scripts too:
on a Docker Desktop bind mount, vfat, or an ext4 casefold directory an
agent-token `script:"daily"` got SCRIPT_NOT_FOUND while the administrator
ran it — a compatibility regression FR-012 does not permit (codex r4 #1).

Maintainer's decision applied: on a case-insensitive mount the exact
spelling cannot be verified without listing, so the ONE directory listing
happens only in that branch — after the constant-cost probe has proven
the fold — and the stored basename is matched byte for byte (a
case-folded entry is reported as stored and probeCandidates refuses it,
as before). Case-sensitive lookups, which every native Linux volume is,
keep the O(1) probe and never list. A listing the process cannot perform,
or an entry gone between probe and listing, still reports unverifiable
and fails closed. darwin (F_GETPATH) and Windows (FindFirstFile) are
untouched, as is the administrator's directory-based Resolve (SC-005).

Retained effect, documented in agent-tokens.md (retained-effects list),
overview.md and troubleshooting.md: a Linux case-folding mount pays one
listing per existing candidate; a missing name pays none and the refusal
shape is unchanged.

Tests: TestResolveScoped_OnAFoldingDirectory (renamed; the exact-name
cell is inverted — runs for scoped callers and administrators alike with
exactly one ReadDir; backdoor.JS still refused non-disclosing; entryName
reports the on-disk spelling; a failed listing fails closed) and
TestEntryName_ExactOnACaseSensitiveLookup now counts 0 ReadDir. The
round-3 skips and the scoped fail-closed branch of the case-distinct
test are removed — both resolvers agree everywhere; the no-listing
count test skips only where entryName itself must list. Verified in
Docker natively with -race and with GOTMPDIR/TMPDIR on a folding bind
mount; mutation check (entryName never lists) fails 4 subtests natively
and the ExtensionCaseIsExact/CaseDistinct scoped cells on the mount.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scope): PR H0 review round 5 — the scoped resolver answers from a directory-generation-validated exact-name index on Linux/BSD (FR-012)

The round-4 fallback listing was paid only when the case-folded probe hit,
so on a Linux/BSD case-folding mount the presence of `backdoor.JS` cost
O(directory) while absence cost O(1): a timing oracle on the stored names,
and the "retained exception" that documented it is not a Spec 105 retained
effect. The scoped resolver now keeps a per-scripts-directory index of the
exact spellings, listed once per directory generation (mtime+ctime+size+ino,
one Lstat of the directory per request, a 2 s settle window for coarse
timestamps) and answered by an O(1) set lookup; only exact hits are probed.
The listing is paid when the directory changes, never per request, whatever
name is asked for. darwin (F_GETPATH), Windows (FindFirstFile) and the
administrator Resolve are unchanged; the fold-probe code is deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(codescripts): drop the dead Windows skip from the Linux-tagged index test

staticcheck SA4032: the file is built with !darwin && !windows, so the
runtime.GOOS == "windows" guard could never fire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scope): PR H0 review round 6 — the scoped stored-script index is maintained off the request path; no scoped request ever lists (FR-012)

Codex round 6 #1 (confirmed by probe: a cold scoped miss on Linux cost
24 µs in an empty scripts directory and 4.5 ms in one holding 10 000
entries, one inline listing on the request goroutine): the first scoped
request, the first after a directory-generation change, and every request
inside the 2 s settle window rebuilt the stored-name index INLINE, so a
refusal's timing class followed the directory population.

Structural answer (Linux/BSD only; the darwin/Windows probe path and the
administrator resolver are untouched):

- codescripts.Warm(dir) builds the index synchronously on the caller's
  goroutine (a rebuild already in flight is waited for, then Warm lists
  again). NewMCPProxyServer warms once the scripts directory is known and
  scriptsDir re-warms, single-flight and on its own goroutine, when the
  active config file path moves.
- The request path is exactly one Lstat of the directory plus the O(1)
  set lookup. A moved generation, a never-built index or an unsettled one
  past its refresh window schedules ONE asynchronous single-flight rebuild
  (in-flight flag, landed channel, no ticker) and the request is answered
  from the index that exists: an exact hit is re-probed by Lstat and
  opened no-follow (removed/replaced fails closed), a script added since
  the listing is refused until the rebuild lands, a never-built index
  misses every name, a failed build refuses as unreadable — where the
  administrator's directory read refuses too.
- The rebuild holds no lock across readDir, stamps before listing,
  installs the map atomically and re-checks the generation under the lock
  after listing (list-then-stamp race), listing again if it moved. The
  settle window changes from "re-list every request" to "schedule one
  async refresh per window".

Tests (Linux-tagged; each proven to bite by mutation in Docker): cold
request with 10 000 entries vs none — zero listings on the request
goroutine, equal Lstat counts, one rebuild scheduled, executes once it
lands; generation change — the request lists nothing, the async rebuild
lands (held seam and the live goroutine), the next request sees the
script; removed script fails closed before the rebuild lands; unsettled
index refreshes at most once per window at unchanged request cost; Warm
lists after an in-flight rebuild; unlistable directory — cold not-found,
then non-disclosing unreadable, administrator same reason. Seam-changing
helpers quiesce in-flight rebuilds first. Shared scoped tests warm the
index as the server does; the server fixture lands the refresh after each
write. Docs state the off-request-path index and the milliseconds window
for scoped callers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scope): PR H0 review round 8 — the stored-script index answers only for its own generation, and a hit's directory generation is rechecked after the open (FR-012)

codex r7 found two genuine issues in the Linux/BSD stored-name index:

1. MUST-FIX: storedNamesFor scheduled a rebuild when it found the index
   behind the directory's generation, but still answered from the stale
   index while that rebuild was merely scheduled or in flight. A stale
   index that once listed an entry under an EARLIER spelling stayed good
   enough to authorize it — on a case-folding mount, warm `report.js`,
   rename it to `REPORT.JS` (moving the directory's generation), and the
   stale index's own Lstat of `report.js` still succeeds by folding onto
   the renamed file. The index now answers ONLY for the generation it was
   built against; any mismatch (never built, behind, or a rebuild merely
   scheduled/in flight) refuses exactly as a never-built index refuses,
   fail closed, until its own rebuild lands off the request path. A second
   race closed the same way: after an exact-set hit's no-follow open, the
   directory generation is read once more and compared to the one read
   before the lookup, closing the descriptor and refusing on a mismatch
   (gen-before == index.gen == gen-after proves the opened entry is the one
   the index vouched for).

2. SHOULD: rebuild() re-listed without bound while the directory's
   generation kept moving, and Warm could block on it forever. One rebuild
   now gives up after maxRebuildAttempts (3) listings — the next request's
   own generation check finds it stale and refuses fail-closed rather than
   this loop spinning to prove something it never can — and
   scheduleRebuildLocked withholds a new rebuild goroutine for
   rebuildBackoff (1s) after the previous one ends, so a continuously
   changing directory cannot resume the same unbounded listing cost one
   goroutine later. Warm's own explicit rebuild does not consume this
   backoff, so it never delays the very first real change shortly after
   startup.

Red tests first (storednames_other_test.go): a stale index refuses a
renamed entry rather than authorizing it (the round 7 finding's exact
repro, via the lstat/generation seams); a generation change between the
candidate probe and the open refuses and never reads the swapped content;
a continuously changing directory bounds one rebuild's listings and lets
Warm return; the backoff withholds a new rebuild until it elapses. Round-6
tests that asserted the old "answers from the existing index while stale"
behavior are updated to the new fail-closed contract (one directory Lstat
decides staleness, no candidate probe) with the round noted inline; the two
successful-hit lstat counts gain the post-open recheck.

Verified: go test -race -count=1 ./internal/codescripts/... (darwin, and
Linux via golang:1.26 in Docker — root, non-root/uid1000, and against a
real case-folding bind mount from the macOS host); go test -race -count=1
./internal/httpapi/...; go test -race -count=1 -skip 'E2E|Binary|
MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|
TestSocketInfoEndpoint' ./internal/server/...; go test -tags server
./internal/serveredition/...; both editions build; go vet clean on darwin,
GOOS=linux, GOOS=windows, GOOS=freebsd; gofmt clean on touched files;
internal/server/testdata goldens untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scope): an unreadable scripts directory is refused as unreadable on the first scoped request

Round 8's "stale or never-built index answers not-found" rule made a
directory that lost its read bit after the index was built report
not-found until an async rebuild recorded the error — the refusal shape
depended on index state, and the cross-platform unreadable-directory
tests failed on Linux CI (non-root). The request path now opens the
directory (one constant-cost syscall, no readdir) and answers the
unreadable form immediately, the same reason the administrator's
listing gives, on every platform.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scope): PR H0 review round 9 — the index authorizes only once settled, the descriptor's spelling is proven at open on darwin/Windows, the generation includes the device, and the index map is bounded (FR-012)

Four codex round-8 findings, all MUST/SHOULD-fixed:

1. An unsettled stored-name index (Linux/BSD) could authorize a
   case-renamed file when the rename landed within the coarse-timestamp
   settle window, because a matching generation alone was trusted before
   the stamp was old enough to rule out a write still landing on it. The
   index now authorizes a hit only when settled; the rebuild-scheduling
   cadence is unchanged.

2. On darwin/Windows the exact-spelling probe ran only before the open, so
   a case-rename racing openScriptFile's own open could execute the wrong
   spelling, and a probe-call failure was treated as a match (fails open).
   The opened descriptor's own stored spelling is now proven post-open
   (F_GETPATH on the executed fd / GetFinalPathNameByHandle), authoritatively,
   on every platform; a probe failure no longer admits a candidate.

3. dirGeneration omitted the device, so a bind-mount swap to another
   filesystem colliding on inode/size/mtime/ctime could reuse a stale
   index. st_dev is now part of the generation tuple on every unix variant.

4. The stored-name index map retained an entry for every scripts directory
   ever used. Warm now keeps only the active directory's index, and the
   map caps at 4 entries (LRU) for bare (never-Warmed) use.

Fixing (1) exposed a Linux-only internal/server test regression (fixtures
write-then-resolve within the same test, always inside the settle window)
caught only by the maintainer's own non-root-Linux Docker repro; fixed with
an exported, test-only codescripts.SetIndexClockForTest seam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(codescripts): exercise the post-open spelling proof with a rename Windows allows

Windows refuses to rename a file another handle holds open, so the
rename-after-open race cannot happen there and the test failed with a
sharing violation. The portable half of the race — case-rename between
the pre-open probe and the open, which APFS and NTFS fold onto the
renamed entry — now carries the assertion on both platforms; the
after-open variant stays darwin-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scope): PR H0 review round 11 — bind scoped resolution to one directory descriptor per call, refuse a following Windows reparse point, and make async rebuilds cancellable (FR-012)

Three findings from round 9's audit, all fixed per the maintainer's full
structural decision:

1. MUST-FIX (Linux/BSD directory-path ABA): every earlier round's scoped
   resolution re-resolved scriptsDir BY PATH at each step of one request —
   the generation read, the candidate probe, the open, and the post-open
   recheck were four independent lookups. A replaceable symlink, ancestor
   directory, or bind mount retargeted between two of those steps and back
   before the next let each step separately agree with a different
   directory than the others saw; st_dev (round 9) rules out a substitution
   visible during one snapshot, never an alternation across several. Fixed
   by binding the whole request to ONE retained directory descriptor
   (dirfd_other.go): opened by path exactly once (openScopedDir), then
   every generation read (fstatDirGeneration), the candidate probe
   (fstatatEntry, AT_SYMLINK_NOFOLLOW) and the open itself (openatEntry,
   O_NOFOLLOW|O_NONBLOCK|O_CLOEXEC) are relative to that same descriptor —
   no path is resolved twice. The rebuild goroutine lists through the
   identical construction (listScopedDirOnce), so the listing and the
   generation it records for it come from one open. Request cost stays
   O(1): one open, two fstats, one fstatat, one openat for a hit.

2. MUST-FIX (Windows reparse-point race): openScriptFile Lstat'ed the path
   and then called os.Open, which DOES follow a reparse point planted (on
   the candidate or a symlinked ancestor) between the two; the post-open
   proof compared only a basename, which any identically-named file reached
   through the reparse point satisfied. Fixed: openScriptFile now opens via
   windows.CreateFile with FILE_FLAG_OPEN_REPARSE_POINT (the Windows
   equivalent of O_NOFOLLOW — no check-then-open window) and refuses a
   reparse point or directory via GetFileInformationByHandle
   (open_windows.go); the scoped post-open proof compares the opened
   descriptor's FULL normalized path (openedFinalPath) against the scripts
   directory's own final path — obtained from a handle opened once per
   request with FILE_FLAG_BACKUP_SEMANTICS — plus the exact basename
   (entryname_windows.go, storedspellings_probe_windows.go), so a
   retargeted ancestor cannot substitute an outside file under the same
   name.

3. SHOULD (cancellable rebuilds): each stored-name index now owns a
   context; LRU eviction and Warm's pruning of every other directory cancel
   it before dropping the index, and the ASYNC (request-scheduled) rebuild
   goroutine checks it between listing attempts and once more before
   installing, so a cancelled rebuild stops promptly and installs nothing
   for a directory nobody will query through it any longer. Warm's own
   synchronous rebuild is deliberately NOT cancellable (the caller is
   blocked on it and trusts its result) — only the async path honours ctx.

Linux-tagged tests (storednames_other_test.go) cover: a real symlink
retarget between a session's own steps proving the ABA fix
(TestResolveScoped_DirectoryPathABA); the identity check refusing a
same-stamp-but-different-device descriptor
(TestStoredNamesFor_IdentityMismatchIsAMiss,
TestDirFdGeneration_ReadsTheDevice); eviction stopping and not installing
from an in-flight async rebuild via a WaitGroup seam
(TestStoredNames_EvictionCancelsAnInFlightRebuild); every existing round
4-9 test re-pointed at the new fd-bound primitives and their own bounded
cost counters. Windows: GOOS=windows go vet and go test -c both compile; a
real Windows run is not available here, so the Windows code mirrors the
darwin structure and stays minimal. darwin is unchanged beyond the shared
five-return signature. Docs (overview.md, troubleshooting.md,
agent-tokens.md) note the single-descriptor binding and the Windows
full-path/no-reparse proof.

Verified: go build (both editions); gofmt; go vet on darwin/linux/freebsd/
windows for internal/codescripts; GOOS=windows go test -c; go test -race
-count=1..3 -shuffle=on natively on darwin; go test -race -count=2 non-root
in golang:1.26 Docker for internal/codescripts (the required H0 command);
go test -race -count=1, non-root, full internal/server suite with the
project's skip regex (294.8s, matching prior rounds' baseline) — no
regression; go test ./cmd/mcpproxy/; go test -tags server
./internal/serveredition/...; git diff -- internal/server/testdata is
empty (no golden touched). golangci-lint v2 could not run — pre-existing
environment issue (its Go 1.25 build refuses this repo's Go 1.26 toolchain
line), unrelated to this change and unchanged since round 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scope): PR H0 review round 13 — unify darwin/Windows onto the shared index + retained-descriptor design (FR-012)

Maintainer decision on codex round-10's six findings: rather than patch
darwin and Windows separately, unify every platform onto the same design.

darwin joins the Linux/BSD fd-bound implementation (dirfd_other.go,
storednames_other.go: build tag `!darwin && !windows` -> `unix`) — the
per-directory exact-spelling index, off the request path, answers a scoped
lookup in O(1) regardless of whether a differently-cased entry exists
(closes the finding-3 timing oracle for darwin). F_GETPATH survives as an
additional, belt-and-suspenders basename proof on top (extraVerifyOpened,
entryname_darwin.go), since openat's own identity binding already proves
the parent (closes finding 1: a retargeted scriptsDir symlink cannot
redirect an already-open fd).

Windows gets a parallel index implementation (storednames_windows.go)
built from a directory HANDLE retained for the whole request; the
candidate probe and the actual open are both performed RELATIVE TO THAT
HANDLE via windows.NtCreateFile (OBJECT_ATTRIBUTES.RootDirectory), never a
fresh path lookup a rename or reparse point could redirect (closes finding
2's ABA repro structurally: NT handles, like Unix fds, are bound to the
file object, not the path). Sharing widened to READ|WRITE|DELETE on both
the new relative opens and open_windows.go's administrator path (finding
4).

Finding 5 (SHOULD): a process-wide semaphore (rebuildsemaphore.go, cap 2)
bounds concurrent async rebuild goroutines across both platform index
implementations — a rebuild that cannot acquire a slot is skipped, not
queued, and the next request retries. Finding 6 (SHOULD): the `unix` build
tag (plus open_unix.go, which had the identical latent gap) excludes
plan9/js/wasip1, which now build via a new fail-closed fallback
(fallback_other.go).

Sibling sweep: Windows's CreateFile+FILE_FLAG_BACKUP_SEMANTICS does not
itself refuse a non-directory the way unix's O_DIRECTORY does — the
scripts directory replaced by a plain file — so defaultWinOpenScopedDir
now checks FILE_ATTRIBUTE_DIRECTORY explicitly.

Verified: full native darwin suite (-race, shuffle, real symlink-retarget
ABA test) green; non-root Linux in Docker green; go vet clean for
linux/freebsd/windows; GOOS=windows go test -c compiles; GOOS=plan9 and
GOOS=js/wasm builds succeed (previously failing outright). Both editions
build; internal/server (skip regex applied), internal/httpapi,
internal/serveredition and cmd/mcpproxy all green. No tool-surface golden
touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scope): PR H0 — a directory candidate is non-regular on Windows, never unreadable

Without FILE_FLAG_BACKUP_SEMANTICS (deliberately not requested: it lets
a SeBackupPrivilege holder read past ACLs, which os.Open never did)
CreateFile refuses a directory with ERROR_ACCESS_DENIED before any
attribute is visible, so TestResolve_Directory reported "unreadable"
where the pre-105 administrator answer is "non-regular" (SC-005).
Classify that case from the attributes; the scoped relative open maps
STATUS_FILE_IS_A_DIRECTORY the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scope): PR H0 review round 14 — Windows path-size off-by-one and fallback-platform test compilation (FR-012)

Two codex r11 SHOULD findings:

1. entryname_windows.go's finalPathOfHandle retried the
   GetFinalPathNameByHandle call only when n > len(buf). Windows reports
   the required size INCLUDING the null terminator when the buffer was
   too small, so a resolved path whose length makes the first call
   report n == len(buf) also needs a retry — that boundary previously
   fell through and returned a truncated/unspecified buffer, wrongly
   refusing a valid exact-length scoped script as not found. Fixed to
   n >= len(buf); GetFinalPathNameByHandle itself is now a seam
   (getFinalPathNameByHandle) so a table test can drive the retry at
   exactly that boundary (one under, exact, one over) without needing a
   real handle whose resolved path happens to be exactly 1024 UTF-16
   units.

2. GOOS=plan9 and GOOS=js test compilation for ./internal/codescripts
   failed before reaching fallback_other.go's fail-closed behavior:
   codescripts_test.go's shared tests call warmStoredNames and
   quiesceIndexRebuilds, but those helpers existed only in the
   unix-tagged and windows-tagged test files, and open_fifo_unix_test.go
   was tagged !windows despite using syscall.Mkfifo (Unix-only). Added
   fallback_other_test.go (!unix && !windows) with trivial helpers
   matching the unix/windows ones' signatures, and retagged
   open_fifo_unix_test.go to unix.

Verified: GOOS=windows/plan9(amd64)/js(wasm) go test -c -o /dev/null all
compile; go vet clean on darwin/linux/freebsd/windows; both editions
build; native darwin go test -race -shuffle=on and non-root
golang:1.26 Docker go test -race -count=2 both green for
./internal/codescripts/; full ./internal/server/... suite (skip regex
applied) green. gofmt clean; no tool-surface goldens touched. Final
self-review sweep of all H0 commits found no further high-confidence
issues.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(codescripts): force the directory mtime forward in the Windows race test

TestStoredSpellingsOf_PostOpenProofCatchesARaceOnTheOpenedDescriptor
wrote a second file into the directory and expected LastWriteTime to
have moved by the time verifyUnchanged re-read it, but NTFS on the CI
runner did not reliably surface a distinct timestamp within the test's
tight window (fatal error: "An error is expected but got nil").
Explicitly force the mtime forward with os.Chtimes, the same
mitigation the unix counterpart
(TestResolveScoped_GenerationChangeBetweenLookupAndOpenRefuses) already
uses for the identical class of coarse-clock flake — the assertion is
about the recheck logic, not filesystem timestamp granularity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(scope): PR H0 review round 15 — bound the Windows final-path resize loop against a growing path (FR-012)

finalPathOfHandle (entryname_windows.go) retried GetFinalPathNameByHandle
exactly once on an oversized-result report, then unconditionally sliced
buf[:n]. If the resolved path grew again between that first oversized call
and the retry (e.g. another process extends the path while the
delete-shareable handle stays open), the retry's own n could again exceed
the just-resized buffer, and buf[:n] panicked with a slice-bounds error.

Turn the single retry into a bounded loop (finalPathNameMaxAttempts = 4),
slicing buf[:n] only once a call's n actually fits the buffer it was given;
exhausting the bound returns a plain error instead of slicing a stale
buffer. The sole caller (winOpenedBaseName -> verifyUnchanged) already
folds any non-nil error here into errSpellingUnproven, the existing
non-disclosing refusal (SC-005), so no new error path needed threading
through.

New table test TestFinalPathOfHandle_GrowingPathDoesNotPanic covers
exact-fit, one-retry, forced second growth (proves no panic and a clean
error when the bound is exhausted), and growth that settles on the last
permitted attempt.

Left the round-12 SHOULD finding (unbounded per-rebuild index memory)
untouched per maintainer directive -- accepted residual, now recorded as
such in research.md's new H0 section. The round's third reported
MUST-FIX (SEARCH behavior deletion in internal/index/bleve.go) does not
apply to this PR: that file is untouched by this branch's diff against
origin/main and already carries the underscore-segment fix on the base
this branch is rebased on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(scope): PR H0 review round 17 — Warm blocks on the shared rebuildSlots semaphore (FR-012)

Warm's own synchronous, population-sized directory listing bypassed the
process-wide rebuildSlots semaphore that async rebuilds already respect,
so the documented "at most maxConcurrentRebuilds concurrent listings,
process-wide" bound did not actually hold once more than one Warm call
was in flight for different directories (e.g. two active-config-path
moves each spawning their own async warmStoredScripts goroutine).

Warm now acquires a rebuildSlots slot too, but with a blocking acquire
(it cannot skip the work like the non-blocking async scheduler does),
taken outside idx.mu so it can never hold up another goroutine. Applied
identically to the Windows mirror, which shares the same semaphore.

A second, lower-severity half of the same finding — an older Warm(A) can
be pruned by a concurrent Warm(B) and finish rebuilding an unreachable
index — is an accepted, self-healing residual (wasted work only, never
stale/incorrect data) and is documented in research.md rather than fixed
with more locking.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(scope): LanguageMismatchError was the one refusal that ignored disclose (FR-012)

codex gpt-5.6-sol cross-model review round 1 (post-main-merge) found that
resolve()'s LanguageMismatchError branch was the only refusal in
codescripts.go that never checked the disclose flag: NotFoundError,
AmbiguousError and InvalidError all thread it through to their own
NonDisclosing() form, but an explicit `language` argument that contradicts a
found script's real extension went straight to the administrator-detail
error unconditionally. A scoped caller who always sends a language no real
script could satisfy could use the resulting type split (LanguageMismatchError
vs the non-disclosing NotFoundError) as a per-guess exists/not-exists oracle,
cheaper and side-effect-free compared to the already-accepted probe of trying
to execute a guessed name.

Fix mirrors the existing AmbiguousError/InvalidError pattern exactly: add
Undisclosed + NonDisclosing() to LanguageMismatchError (withholding the host
filesystem facts — Extension, Derived — while keeping the caller's own
Requested input and the distinct INVALID_LANGUAGE classification, consistent
with how Ambiguous/Invalid keep their own SCRIPT_UNUSABLE classification
rather than collapsing into SCRIPT_NOT_FOUND), and gate the resolve() call
site on `disclose` the same way the sibling branches already are.

Other review findings evaluated and not actioned in this PR:
- tool-calls activity log exposing code_execution Arguments to a "*"-scoped
  token (internal/runtime/runtime.go) — pre-existing, outside files this PR
  touches, and the REST management API is explicitly out of scope for Spec
  105 (spec.md "Transport and caller scope"; tracked separately as the #1166
  entitlement follow-up already named in GetToolCalls's own doc comment).
- anonymous /mcp caller under the default require_mcp_auth=false reaching the
  administrator path — the named, spec-blessed FR-001 caller-kind-first
  exception (spec.md amendment 6, SC-005), matching this repo's documented
  security model for the /mcp surface generally, not something this PR
  introduces.
- storednames rebuild scheduling as a cache-timing side channel — real
  (probeCandidates alone uses the index; the administrator path still reads
  the directory directly) but only signals "the directory changed recently,"
  not which names exist; consistent with the accepted residuals research.md
  already documents for this same subsystem (rebuildSlots contention,
  Warm/prune race).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Dumbris added a commit that referenced this pull request Oct 20, 2026
…line passes (R2-1 follow-up)

The EXP-4 TOCTOU lab run exposed a gap in the first R2-1 fix: when EVERY
first-seen tool is held by the baseline scan, no baseline marker is written
(FR-025), so the next identical tools/list is again a baseline pass and the
"stranded pending record, unchanged since recorded" promotion (MCP-2931 #4)
approved the held tool as auto-baseline and cleared its scan evidence.

The promotion now also requires the baseline scan to be green for the tool.
The hold evidence on a pending record is refreshed against the LIVE contract
on every baseline pass, so it always describes what the operator sees: a
poisoned contract stays pending with its evidence across any number of
repeats; a server that swaps to a clean contract gets only that clean
contract baselined (once seen unchanged), and a swap back is a rug pull.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Dumbris added a commit that referenced this pull request Oct 30, 2026
…line, diacritics, tr/el corpus, name squat (R2-1..R2-5) (#1714)

## Summary

Round-2 TPA red team (2026-10-20) against the hardened detect engine and
tool-level quarantine: every finding was reproduced on this machine
first, then closed with one commit-sized change per component, each
verified end to end on the local instance and cross-reviewed. Report:
`docs/research/2026-10-11-tpa-attack-lab/round2-2026-10-20.md` (spec,
evidence, post-fix results, review rounds).

| Finding | Before | Protection | After |
|---|---|---|---|
| **R2-1 (HIGH)** auto-baseline poisoning: an unquarantined first-seen
server auto-approved its whole toolset with no scan; an 18-vector
poisoned catalog was live, indexed and callable | `R2-1 HOLE` (22
approved rows, poisoned tool called) | **P1**
`internal/runtime/tool_quarantine.go`: the first-seen toolset is scanned
once with `scanner.ScanToolMetadataVerdict` (peers included, the same
fail-closed seam `trust_mode: scan` uses); verdict read per tool — clean
→ `auto-baseline`, hard/soft finding → **pending** with hold evidence,
degraded scan → every tool pending. Follow-ups: the stranded-pending
promotion (MCP-2931 #4) is gated on the same scan (an all-held toolset
writes no marker, so the next identical list used to promote it); a
clean verdict never erases existing hold evidence | `R2-1 HOLDS` (both
poisoned vectors pending, benign controls still auto-baseline, call
BLOCKED) |
| **R2-3** Turkish/Greek overrides scan clean | 4 BYPASS rows | **P3**
`phrase_injection_i18n.go`: `el` verb-first family
(final-sigma-insensitive); `tr` SOV override + verb-first +
system-prompt disclosure families, imperative suffixes only, consumed
trailing boundary (`phraseFamily.trailingBoundary`) so suffix negation
stays clean; corpus now de fr es it pt nl pl ru uk el tr zh ja ko |
DOWNGRADED (soft, like every non-English family) |
| **R2-4** combining diacritics defeat de-obfuscation | 2 BYPASS rows |
**P2** `deobfuscate.go`: NFD → homoglyph fold → strip Mn on Latin bases
→ NFC, on the phrase view and the mixed-script view; other scripts'
marks untouched | BLOCKED (hard) |
| **R2-5** name squat with rewritten description (round-1 survivor) | 1
BYPASS | **P4** `shadowing.go`: SOFT shape 1b — distinctive shared name,
genuinely different description, no own-server provenance in the text;
MCP-3520 pair unchanged | DOWNGRADED (soft) |
| **R2-2** unquarantine race (control) | HOLDS | — | HOLDS |

Offline harness: **79 blocked / 40 downgraded / 8 BYPASS → 83 / 44 / 0
BYPASS**, every class change upward.

## Verification (all on this machine)

- `go build ./...`, `go vet`, `go test ./internal/runtime
./internal/qapilot/... ./internal/security/... ./internal/storage/...
./tests/tpa-attack-lab` green; CI-config `golangci-lint` (bare and
`-tags server`) clean except the pre-existing `event_bus.go:1020`
reflect.Ptr nit (untouched).
- `scripts/tpa-lab-redteam-experiments.sh` (fresh state, full rebuild):
`suite result: PASS (every experiment holds)` — EXP-4 (TOCTOU) now holds
at discovery and was re-premised accordingly (an approved poisoned
first-seen contract is scored HOLE).
- `SKIP_BUILD=1 scripts/tpa-lab-redteam-round2.sh`: `R2-1 HOLDS`, `R2-2
HOLDS`, `round-2 result: PASS`.
- `go run ./cmd/tpa-redteam`: `0 BYPASS, 0 baseline-miss`.

## Review

- Multi-lens adversarial review (5 lenses × 3 skeptics per finding): 20
confirmed findings, all fixed or documented in the report (incl. the
repeat-pass gap it found independently of the EXP-4 lab run).
- opencode `gpt-6.1-sol` read-only cross-review, 3 rounds:
`REQUEST_CHANGES` (7, 6 fixed + 1 refuted with a pinning test) →
`REQUEST_CHANGES` (1, fixed) → **`VERDICT: SHIP`**. Verbatim in the
report.

## Documented, not implemented (maintainer decisions)

- `llm_judge` backstop for languages the corpus does not cover (own
spec).
- P4's provenance exemption is text-based and therefore gameable, the
squat finding is symmetric/order-dependent, and the live
ElevenLabs/Kaggle `list_models` descriptions name no server (both would
be soft-flagged once). Keep the exemption, drop it, or demote the shape
to `info` is a product call; the shape is a review hint, not a boundary.
- The plain server-unquarantine door still promotes pending tools
without a scan (R2-1's second reach); the informed review path shows
findings first.

Rebased onto current `main` (Spec 118 auto-rescan, #1689): the only
conflict was an adjacent insertion in `tool_quarantine.go` (kept both);
one TOCTOU test assertion now pins Spec 118's evidence semantics (a
moved pending definition is re-described as `awaiting_review`), and the
lab's `scan_gate_logged` probe accepts #1689's reworded scan-hold log
line (EXP-6b/6c were INCONCLUSIVE on current `main` for that reason
alone). The dockerized suites were re-run on this branch after the
rebase.

Lab/tests: new unit tests per protection, EXP-4 `held`/`yes`/`no`
precondition tables, round-2 runner guard tests (env scrub, pinned dirs,
image, fresh run dir, fail-closed exit).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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.

1 participant