Skip to content

Research specification: deterministic MCP and agent trust-boundary replay

ID: research-spec-agent-mcp-trust-v1
Status: Proposed (2026-11-03), research and design only — not an implementation authorization.
Issue: #803, parent #788.
Evidence: source audit · ADR candidate · synthetic fixtures.

Goal

Define an offline, deterministic and falsifiable oracle for the two current MCP tools (transcribe and opt-in ask_human) and local sendto. It must catch the existing per-request budget lifetime error even when the pure InterruptBudget class passes isolated tests. It must separately catch unanswered audible questions that currently go uncharged. It must distinguish a YazSes direct transmission from a tool result delivered to a host that may later go online.

No real microphone, client, private data, external network connection, model, or prompt is needed.

Fixture schema

One JSON object per line:

  • id, description, boundary (interrupt / transcribe / tool_result / handoff / logging / identity / compatibility).
  • setup: enabled, limit, daemon_id, time_start_ms, optional source_policy.
  • events: list of objects with integer t_ms and kind; equal times retain list order.
  • expect: asserted metrics and a note on baseline current behavior where the proposed invariant is not yet enforced.

Events: ipc_ask (request_id, client_ref, question), tts_started, tts_failed_before_audio, tts_failed_indeterminate, listener_answer, listener_empty, hold_start, hold_end, daemon_restart, window_advance (delta_ms), transcribe_call (synthetic path class), tool_result (synthetic text), fake_backend_exception (fake message), custom_handoff (target and synthetic text), client_network_action, debug_log, export_report, mcp_client_name (display_name, verified).

Expected metrics: spoken_count, refused_count, deferred_count, returned_answers, client_disclosures, direct_yazses_network_sends, auto_submit_count, raw_sentinel_in_tool_error, raw_sentinel_in_export, verified_identity_claims, known_current_defect. Each fixture asserts only applicable fields. No real file path, secret, log or uploaded transcript.

Security invariants

I1: One budget per running daemon. Across different IPC requests, MCP child processes, and arbitrary caller labels, attempts that may have spoken within the configured sliding hour ≤ max_per_hour. A service object must not own the only quota state. A real daemon restart is a separate generation: current policy may reset an in-memory budget at restart; cross-restart persistence is out of scope.

I2: Atomic budget admission. Two requests racing with exactly one remaining slot cannot both reserve an audible question; admission and reservation happen under one synchronization policy. Never use a budget per caller as the sole safety gate.

I3: Count attention, not answer rate. A TTS question that may have been spoken consumes a slot even when the listener answers nothing, times out, or returns corrupted transcription. A refused request during a held key, a disabled feature or a provable failure before playback consumes no audible slot. An uncertain partial playback remains charged until proven silent.

I4: No speaking during physical hold. An active hold defers without audio, injection or quota consumption. The answer returned from ask_human reaches the MCP caller but never types into the focused window.

I5: Truthful identity. caller="mcp" is a generic transport label, not authentication. An unverified client-supplied string cannot be represented as a verified desktop app identity; the user is not told a specific agent asked unless identity can be established.

I6: Correct disclosure accounting. transcribe(path) does local recognition and returns text to the requesting MCP host; the host obtains the text (a disclosure even with no YazSes socket). If the host subsequently transmits it, classify that as host-controlled egress, not a direct YazSes network request. The tool itself does not call a cloud transcription endpoint.

I7: Prompt injection remains data. A synthetic media transcript saying "ignore the previous instructions" is passed as literal text; it cannot trigger a YazSes control-plane side effect. The receiving host is responsible for untrusted tool-output handling and approval before privileged actions. No heuristic filter in YazSes is required or assumed effective.

I8: Error privacy. A fake backend exception containing SENTINEL_NOLOG or a synthetic path must not be embedded verbatim in a future redacted public error; current mcp/protocol.py returns str(exc) and should be reported separately as a code-inspected unsafe edge until tested, not silently counted green.

I9: Logs distinguish local DEBUG storage from export. With DEBUG enabled, the local daemon log may contain transcripts (as documented); a shareable filtered report must not contain them. A synthetic marker in DEBUG output must never cross a simulated export boundary.

I10: Handoff never automatically sends. The allow-listed claude:///codex:// local handlers, encoded query parameters, character/URL limits and absence of HTTPS fallback remain enforced. Actual client version/OS submission behavior is unknown and belongs to #800.

I11: Compatibility is separate from transport security. MCP protocol 2025-06-18 support in YazSes and newer 2026-07-28 lifecycle are distinct facts. No automatic upgrade, third tool, network listener or HTTP auth is introduced by this replay.

Required fixture families

ID Scenario Policy oracle
AG-001 Three separate IPC requests, all answered, limit one One spoken, two budget refusals
AG-002 One TTS question played but user stays silent, then retry Retry refused, even with empty first answer
AG-003 TTS fails with proof it emitted no audio Slot can be released
AG-004 TTS fails after potential partial playback Slot is charged
AG-005 Agent asks during hold Deferred, not spoken, not charged
AG-006 Two clients race for one slot At most one spoken
AG-007 Three requests on one persistent service Ordinary positive budget baseline
AG-008 Daemon restart after exhausted budget In-memory window lifetime explicit
AG-009 Spoofed MCP caller label No verified client identity claim
AG-010 MCP transcribes synthetic local media Tool content to host, no YazSes network request
AG-011 Audio content contains malicious instructions as data No control execution, faithful text only
AG-012 Fake exception contains sentinel Negative current protocol error-leak oracle
AG-013 Handoff asked to open HTTPS Refused; no opener
AG-014 Custom URL with delimiters and non-Latin text Exact single-parameter encoding
AG-015 Local DEBUG transcript and shareable report Marker removed from export
AG-016 Host transmits a result later Host egress distinct from YazSes
AG-017 Spoken answer returned with editor focused Only caller receives it; no injected text
AG-018 Default disabled ask_human Not offered, no TTS
AG-019 Unusually long question Resource-risk evidence, not an invented limit
AG-020 Modern MCP host speaks new lifecycle Compatibility warning, not automatic remote server

Explicitly classify AG-001, AG-002 and AG-012 as expected failing checks for the as-built policy. An intentionally red diagnostic may be documented, but must not be silently committed as a failing normal-CI gate. Record separately the current behavior and the desired future oracle.

Pure reducer model

# Research pseudocode only, not a shipped implementation
def admit_ask(state, request):
    if not state.ask_enabled:
        return "refuse-off"
    if state.hold_active:
        return "defer-hold"
    with state.budget_lock:
        state.evict_expired_attempts()
        if state.attempts_in_window >= state.limit:
            return "refuse-budget"
        state.reserve(request.id)  # before any TTS can begin
    return "admit"

def settle_attempt(state, request_id, outcome):
    if outcome == "proved-no-audio":
        state.release_reservation(request_id)
    else:
        state.consume_reservation(request_id)
        # Includes no answer, timeout and unknown partial playback

The harness should be a dependency-free state reducer with injected clock, events and fake transport. A daemon integration regression, if later authorized, must actually invoke Daemon._handle_ask_human on the same daemon object multiple times. Testing two successive calls to InterruptBudget.allow on one manually constructed budget will not catch the current lifetime error.

For MCP result tests, use an injected ToolSpec and mcp.protocol.handle with synthetic output and a fake exception. For opener tests, reuse tests/test_sendto_targets.py and tests/test_sendto_handoff.py rather than duplicating existing controls. For concurrency, schedule with explicit barriers instead of sleeps.

Acceptance metrics

  • 0 quota overruns within the same live daemon; 0 cross-client quota resets; 0 unanswered audible questions outside quota.
  • 0 concurrent admission overshoot; 0 playback while the hold is active.
  • 0 verified-caller assertions from untrusted names; 0 answer injection into an editor.
  • 0 new direct YazSes outbound network calls in replay; client/tool-result disclosure recorded as a distinct boundary.
  • 0 automatic send/Enter; 0 sentinel text in a filtered diagnostic report or future public error.
  • Every assertion reports denominators and whether it was checked in pure replay, daemon fake, or real client. Passing synthetic fixtures is not proof that a cloud AI client is safe.

Proposed sequence: R0 research documents and fixtures (this branch); R1 reviewed safety bug task with delivery milestone; R2 deterministic reproducer that fails on current daemon code; R3 minimal daemon-scoped quota fix and unit tests; R4 full offline tests/ruff/mypy; R5 client documentation and versioned validation under #800/#801. No production default changes are approved by this spec.

No tests or live client experiments have been executed as part of this research-only spec. Follow AGENTS.md, ADR-020 and ADR-030 for any later implementation decision.