e8bae606cb1a82d4cb6f3e544c8ffe28e9617f0c
10
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fa510dd28d |
docs(remote-mcp): restamp the #956 threat-model anchors onto the commit they resolve at
The anchors and their citations moved in the previous commit because gitea_mcp_server.py gained the #948 worker-identity block. The fixture still named the commit the old line numbers resolved at, so the recorded provenance pointed at a tree where the new numbers do not hold. No anchor target or expectation changes; only the recorded commit does. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01F6Vomtndpq2gSBa88Tfcwy |
||
|
|
1dd30ecb15 |
feat(mcp): client/session-aware runtime ownership and provenance (#948)
Two surfaces reported different provenance for one process.
`gitea_get_runtime_context` read the live environment and reported
`client_managed`; `mcp_namespace_health.classify_namespace_probe` derived
provenance from `_safe_env_summary()`, whose `SAFE_ENV_KEYS` allowlist never
contained `GITEA_CLIENT_MANAGED`, `GITEA_MCP_CLIENT_MANAGED`, or
`GITEA_SERVER_PROVENANCE`. That lookup could only ever miss, so the health
surface was structurally incapable of returning anything but `manual_launch`.
Neither model could name which client or which session owned a runtime, so a
healthy daemon serving a second client was indistinguishable from a duplicate,
and the profile-wide duplicate scan walled the whole fleet.
Introduce `mcp_worker_identity` as the one authority, splitting two claims the
old code ran together:
* launch provenance — was this hand-launched from a terminal? Answered from the
environment, which is legitimate because the launcher sets it. Preserves the
#686 wall unchanged.
* session ownership — which live client session owns this runtime now? Answered
only from a live attachment record; no environment flag can establish it.
The module provides collision-resistant worker identities
(`<llm-name>-<UTC-timestamp>-<short-sha>`), an atomic SQLite registry with
fencing epochs, heartbeat-based liveness, generation takeover that supersedes
only a non-live claimant, cohort classification, and failure scoping.
Behaviour changes:
* Registering an existing worker identity fails closed; it is never replaced,
adopted, or merged with. The caller mints a different identity instead.
* A generation held by a live session cannot be claimed by a second one. A
generation whose claimant is not live is taken over with a higher fencing
epoch, so stale ownership cannot permanently strand a healthy daemon.
* A superseded session presenting an old epoch is refused and performs no write.
* Liveness comes from heartbeat freshness; a live PID cannot resurrect an
expired record, and a dead PID withdraws liveness.
* Workers sharing a role or profile no longer trigger a profile-wide duplicate
block, provided each carries a distinct identity. Processes with no identity
evidence remain classified as duplicates, so the #686 wall still holds.
* Runtime failures are scoped to a worker identity or generation, never to a
profile or the fleet.
* Reconnect guidance no longer defaults to Codex. An unidentified client gets
host-agnostic steps; `gitea_request_mcp_reconnect(client=...)` defaults to
resolving the client from the live attachment record.
* `resolve_bound_remote` keeps a bound namespace on its remote instead of
falling through to the `dadeschools` library default.
Absence of proof is now reported as `unproven` rather than asserted as
`manual_launch`. Both still fail closed — `is_client_managed` is unchanged, so
nothing previously refused is now permitted — but remediation names the proof
that is actually missing instead of describing a terminal launch it cannot
evidence. The #686 test is updated for that vocabulary and keeps every
wall-preserving assertion.
Threat-model anchors and their citations in docs/remote-mcp/threat-model.md are
restamped for the line movement in gitea_mcp_server.py.
Tests: tests/test_issue_948_client_session_provenance.py adds 43 cases covering
Codex/Gemini/Antigravity/Claude attachment, same-client new session, cross-client
takeover after a session ends, two live conflicting sessions, stale records,
missing attachment proof, environment flags without attachment, mixed
generations, duplicate cohorts, the hardcoded-client regression, explicit PRGS
selection, default-remote host drift, cross-surface agreement, and fail-closed
handling without false reconnect loops. Synthetic identifiers throughout.
Full suite from a branches/ worktree: 28F/5953P/6S at head vs 28F/5910P/6S at
merge base
|
||
|
|
58bd852188 |
docs(remote-mcp): restamp the #956 threat model onto the commit it resolves at (review 637 B1)
Commit |
||
|
|
126d76ad28 |
docs(remote-mcp): restamp the commit the #956 anchors resolve at
The anchors were re-derived against the #708 wiring commit; record that SHA so the fixture states the tree its line numbers were taken from. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> |
||
|
|
e3fa3b263d |
feat(mcp): gate Connected-but-unattached MCP namespaces (Closes #708)
The prior #708 slice added assess_connected_namespace_attachment() as a pure
decision function with no call site: nothing invoked it, so a session whose
role namespaces were Connected at the host but absent from the active session
tool surface still passed every mutation gate. Detection existed on paper only.
This makes it load-bearing.
Decision layer (mcp_namespace_health.py)
- assess_connected_namespace_attachment() gains secret-free telemetry
(connected/attached/required/missing counts, discovery cache hit and age,
reconnect_required, auto_attach_attempted, auto_recovered, error_type) and
reports reconnect_required, auto_recovered and startup_ordering_race.
- Startup ordering: a namespace whose connect completed after the session tool
snapshot cannot be in that snapshot, so parallel multi-role startup is
identified as its own race with the affected namespaces listed.
- attachment_gate_from_session() is a fail-closed gate keyed by
ATTACHMENT_GATED_TASKS. An unassessed namespace does not gate, matching #543
semantics, so this never fabricates a block.
- SANCTIONED_ATTACH_RECOVERY_TOOL names gitea_request_mcp_reconnect (#678) as
the only recovery.
Server wiring (gitea_mcp_server.py)
- New tool gitea_assess_mcp_namespace_attachment classifies the condition and
records a per-namespace verdict in _LIVE_NAMESPACE_ATTACHMENT.
- gitea_submit_pr_review and gitea_merge_pr now consult
_namespace_attachment_gate() alongside the existing #543 health gate, so both
fail closed while a required namespace is unattached.
- Watchdog check-in emits status only, never namespace contents.
The typed condition mcp_connected_namespaces_missing stays distinct from config
drift (#672), transport-closed (#584) and resolver EOF (#685). Recovery never
routes through direct imports, CLI or raw API mutation, profile hopping,
session-state overrides, or process kills.
Docs
- docs/mcp-namespace-health.md documents the tool arguments, startup ordering,
the fail-closed gate, and the telemetry contract.
- skills/llm-project-workflow/SKILL.md states Connected is not attached, and
that preflight proof is live tool visibility plus gitea_whoami on the role
namespace rather than host status alone.
- docs/mcp-tool-inventory.md lists the new tool.
- docs/remote-mcp/threat-model-anchors.json and threat-model.md: 21 #956 anchors
restamped for the line shift these additions caused in gitea_mcp_server.py.
Every anchor was re-derived from its recorded expect substring; none guessed.
Tests
- tests/test_issue_708_attachment_wiring.py (19 cases): typed detection, proof
mapping, reconnect-only next action, auto-attach success and failure,
reconnect rediscovery, multi-role startup ordering, telemetry including a
no-secret-leak assertion, fail-closed gate per role, unassessed and unmapped
tasks not gating, partial attachment gating only the affected role, and no
healthy verdict without attachment proof.
Verification
- tests/test_issue_708_attachment_wiring.py + test_issue_708_mcp_namespace_attachment.py: 24 passed
- namespace/session/runtime/review sweep: 427 passed, 12 subtests
- full suite head: 31 failed, 5885 passed, 6 skipped, 1047 subtests
- full suite base
|
||
|
|
0104a76eea |
docs(remote-mcp): restamp the commit the #956 anchors resolve at
The execution-authorization gate shifted two cited lines in mcp_daemon_guard.py, so the fixture and the document must name the commit where the re-anchored citations actually resolve. Both still named |
||
|
|
a143cd065b |
fix(transport): gate transport execution behind commissioning (review 635)
Addresses B1 and B2 from formal review 635 on PR #966. Both are one defect:
the seam conflated two questions that must stay separate.
recognition: "is this an identifier this system knows, and may it be
bound, pinned and recorded?"
execution: "is this entrypoint commissioned to actually serve on it?"
SUPPORTED_TRANSPORTS answered the first and was then used to answer the
second, so registering streamable-http was the same act as executing it.
With mcp.run fed the bound value, GITEA_MCP_TRANSPORT=streamable-http
reached FastMCP.run -> run_streamable_http_async and started a uvicorn
HTTP server over the whole tool surface with auth=None, no TLS and no
per-request principal. That endpoint is owned by #938 and is an explicit
non-goal of #931. B2 is why B1 was reachable: bound_transport was read
only by reporting sites, so no decision anywhere depended on it.
The fix adds an execution-authorization layer rather than removing the
identifier or mapping it to stdio, both of which would have destroyed the
seam #931 exists to create.
mcp_transport_config gains EXECUTABLE_TRANSPORTS, a strict subset of
SUPPORTED_TRANSPORTS containing only the local transport, plus
TRANSPORT_EXECUTION_OWNER recording that #938 commissions the remote
listener. assess_transport_execution returns a structured verdict:
transport, recognized, executable, allowed, blocker_kind, owner_issue,
reasons and exact_next_action.
mcp_daemon_guard gains assess_serve_authorization, which is the decision
that consumes bound_transport and is what stops it being reporting-only
metadata, and authorize_transport_execution, which enforces two ordered
boundaries: assert_transport_bound first, so an unbound runtime keeps its
pre-existing #695 failure and reason code; then execution authorization,
so a registered but uncommissioned transport is refused before any
listener exists and before any tool can dispatch. TransportExecutionError
subclasses UnsanctionedRuntimeError, so every existing fail-closed handler
still catches it while a caller that cares can distinguish the two cases.
native_runtime_status now reports executable_transports, serve_authorized
and the full serve_authorization verdict.
The entrypoint serves through authorize_transport_execution.
Net effect. stdio: unchanged, still binds and still reaches the runner.
streamable-http: still recognized, still validated, still pinned, still
recorded in decision-lock and audit provenance -- and refused at the serve
boundary with blocker transport_listener_not_commissioned naming #938.
Unregistered identifiers still fail earlier, at bind validation. Unbound
execution keeps the #695 contract. Rebind idempotence and conflict
rejection, session isolation, provenance and secret handling are untouched.
Tests: 63 in tests/test_issue_931_transport_bind_seam.py, up from 42. New
coverage proves stdio reaches the real runner; the remote identifier binds
and is recorded yet never reaches run(); no socket is bound during the
refusal, asserted with a socket.bind tripwire; the refusal names the
transport, the unmet requirement and #938; the decision genuinely consumes
bound_transport, proven by flipping only that value; no mutation is
authorized after denial; verdicts do not leak across runtimes; and the
refusal carries no credential material. Per review 635 the two file-list
scans are now globs over the production surface, and a new test asserts
there is exactly one mcp.run site and that it routes through the guard.
#956 anchors re-anchored for the two lines this change shifted,
mcp_daemon_guard.py 178->195 and 519->583. No other anchor moved.
Full suite from the branches/ worktree: 28 failed, 5864 passed, 6 skipped,
1047 subtests. Base
|
||
|
|
2fb835a1aa |
docs(remote-mcp): stamp the commit the #956 anchors were re-derived at
The anchor fixture records the commit its anchors were taken at, and the threat model must state the same commit, so a reviewer can resolve every cited line at a named revision. Both still named |
||
|
|
c162608175 |
feat(transport): add transport-neutral MCP bind seam
Closes #931.
The transport was a literal passed once at the bottom of the module,
bind_native_mcp_transport(transport="stdio"), and every guard that asks
"is this a trusted native session" resolves that question through the
value bound there. The string was a constant in the authorization chain
rather than configuration, so a remote transport had no way to be
expressed and no way to be told apart from an untrusted offline import.
mcp_transport_config becomes the single source of truth: it owns the
permitted set, the default, and the resolution of deployment
configuration (GITEA_MCP_TRANSPORT) into a validated identifier. Unset
configuration still yields stdio, so existing deployments are unchanged.
streamable-http is accepted so the bind is pluggable; standing up its
listener remains #938. sse is deliberately not registered.
bind_native_mcp_transport now resolves from configuration when no
transport argument is given, validates against that one permitted set
before writing the runtime record, and pins the result. bound_transport
is the shared accessor every transport-aware guard reads; it reports the
pinned value and never the environment, so a post-bind GITEA_MCP_TRANSPORT
change cannot move what a guard observes -- the rule already applied to
the session-state root under #695 AC2. Rebinding the same identifier is
idempotent; rebinding a different one is refused, so two guards can never
disagree within one process. assert_transport_bound fails closed before
mcp.run, so an absent or invalid bind stops the server instead of serving
tools over a transport no guard can name.
The bound identifier is now recorded in durable provenance:
mutation_provenance_fields gains bound_transport, which reaches the
decision lock through mcp_session_state.save_state and the audit records
that already spread those fields. The pre-existing transport field keeps
its trust-class values, so no durable record changes shape.
assess_transport_for_auth_mint reports the bound transport through the
same accessor; its verdict is unchanged.
#956's anchor fixture and threat model are re-anchored for the lines this
change shifts, and the two boundary claims that #931 makes false -- the
"literal stdio bind" in B1 and the stdio contract at mcp_server.py:4 --
are restated. Without this the #956 guard fails, which is what it is for.
Non-goals, untouched: the remote listener (#938), per-request principal
resolution (#932), client-managed provenance policy (#934), TLS and
remote client authentication, role capability sets, repository-binding
semantics.
Tests: tests/test_issue_931_transport_bind_seam.py, 42 tests, covering
default bind, explicit stdio, the accepted non-stdio identifier, an
unregistered identifier, no bind at all, one-value agreement across
guards, post-bind environment tampering, the durable decision-lock
record, the rebind contract, and the #695 protections that must not
regress.
Full suite from the branches/ worktree: 28 failed, 5843 passed, 6
skipped, 1047 subtests. The pinned base
|
||
|
|
b3de9c941c |
docs(remote-mcp): threat model, trust boundaries, and decomposition ruling (Closes #956)
#930 inventoried stdio coupling; nothing stated what the adversary is, what each boundary protects, or why one process may hold credentials for several services. This adds that document as child 2 of epic #929. Adds docs/remote-mcp/threat-model.md covering 10 assets, the 5 adversaries #956 names, 9 trust boundaries, the data flows between them, a 14-entry per-boundary credential inventory, and an explicit decomposition ruling. Findings established from live native evidence at this commit: - Four prgs roles (reviewer, merger, reconciler, controller) resolve to one Gitea account, so separation of duty between approving and landing is enforced only by which process a call reaches. The mdcps tenant has no role separation at all: author, reviewer, and merger share one account. - Any one role process can resolve every other role's credential. gitea_list_profiles reports "credentials present" for other roles because it calls resolve_token on each one. - The Gitea server reads Jenkins and GlitchTip secrets out of the keychain to produce the "authenticated" word in gitea_audit_config's service summaries. - Jenkins and GlitchTip were already decomposed into separate MCP servers; the credential references were left behind in the Gitea configuration. Ruling D1 forbids a single integration process from holding credentials for unrelated services, with one time-boxed dual-run exception for the local fleet that expires with #939. D2 requires separation of duty to be credential-backed, D3 scopes credential resolution to the request principal, and D4 gives coordination state its own authority. Every #929 child from 2 through 10 is mapped to the boundary it implements. Anchors are enforced rather than asserted. #930's inventory anchors into gitea_mcp_server.py had already drifted between |