Multi-Client Permission Mediation
Overview
Wenn der Agent des ACP-Childs requestPermission aufruft, leitet der Daemon ihn nicht einfach an einen einzigen Client weiter. Unter sessionScope: 'single' sieht jeder verbundene Client die Anfrage und jeder von ihnen kann darauf antworten. Ohne Mediation haben späte Votes kein Ziel, zwei Clients können sich bei derselben Anfrage ein Rennen liefern, und ein einzelner bösartiger Client kann den Urheber überschreiben.
MultiClientPermissionMediator (packages/acp-bridge/src/permissionMediator.ts) implementiert den PermissionMediator-Vertrag (packages/acp-bridge/src/permission.ts) und verwaltet den gesamten ausstehenden und aufgelösten Berechtigungsstatus für die Bridge. Es verteilt Votes über eine von vier in PermissionPolicy deklarierten Richtlinien:
| Policy | Auflösungsregel | Anwendungsfall |
|---|---|---|
first-responder | Erster gültiger Vote gewinnt; spätere Voter erhalten permission_already_resolved. | UX für Live-Collaboration über Clients hinweg (Standard). |
designated | Nur die originatorClientId des Prompts darf auflösen; andere sehen permission_forbidden{designated_mismatch}. | Pro-Mandant SaaS, bei der die UI-Oberfläche ihre eigenen Genehmigungen verwalten muss. |
consensus | N-von-M-Quorum über den v1-Client-ID-Snapshot hinweg; intermediäre permission_partial_vote-Events ermöglichen es UIs, den Fortschritt anzuzeigen. | Enterprise-Change-Review, bei dem sich zwei Operatoren einig sein müssen. |
local-only | Lehnt jeden Nicht-Loopback-Voter ab; blockiert, bis ein Loopback-Client auflöst. | Workstations, bei denen Fernsteuerung niemals eine Rechteausweitung gewähren darf. |
v1-Sicherheitslimit: Die
X-Qwen-Client-Idwird selbst angegeben.designatedundconsensusverfügen noch nicht über einen Proof-of-Possession. Ein Client, deroriginatorClientIdbeobachtet, kann diese ID wiederverwenden.{outcome:'cancelled'}wird ebenfalls über das Cancel-Sentinel vor dem Policy-Dispatch geleitet, sodass selbstlocal-onlyCancel nicht als policy-geschützte Auflösung behandeln kann. Für starke Isolierung binde den Daemon an Loopback oder platziere ihn hinter einem authentifizierten Reverse Proxy. Siehe Sicherheitshinweis: v1-Client-Identität wird selbst angegeben.
Responsibilities
- Verfolge jede ausstehende Anfrage (Lebenszyklus
request → vote → resolved). - Aktiviere und deaktiviere anfragebezogene Wallclock-Timeouts (die N1-Invariante: Der Timeout muss synchron innerhalb von
request()aktiviert werden, damit eine sofort abgebrochene Session kein permanent ausstehendes Closure leaken kann). - Verteile Votes über die zum Zeitpunkt von
request()erfasste Policy (eine Änderung der Daemon-Policy während der Ausführung betrifft keine laufenden Anfragen). - Verwalte eine begrenzte FIFO (
MAX_RESOLVED_PERMISSION_RECORDS = 512) kürzlich aufgelöster Anfragen, damit doppelte Votes ein strukturiertesalready_resolvedstattunknown_requesterhalten. - Emittiere
permission_partial_vote(Consensus) undpermission_forbidden(Designated / Consensus / Local-only) auf dem sessionbezogenen EventBus. - Löse ausstehende Anfragen als
{kind: 'cancelled', reason: 'session_closed'}überforgetSession(sessionId)beim Session-Teardown auf. - Weise böswillige oder versehentliche Injektion von
CANCEL_VOTE_SENTINELüber das Wire (InvalidPermissionOptionError) und über vom Agenten veröffentlichte Options-Labels (CancelSentinelCollisionError) zurück.
Architecture
Öffentliche Schnittstelle
interface PermissionMediator {
readonly policy: PermissionPolicy;
request(
record: PermissionRequestRecord,
timeoutMs: number,
): Promise<PermissionResolution>;
vote(vote: PermissionVote): PermissionVoteOutcome;
forgetSession(sessionId: string): void;
}MultiClientPermissionMediator fügt hinzu: peekSessionFor(requestId), pendingCount(sessionId), interner Audit-Publisher usw. BridgeClient hängt nur von der request()-Hälfte ab (strukturelles Sub-Typing – siehe bridgeClient.ts).
PermissionPolicy und PermissionVoteOutcome
type PermissionPolicy =
| 'first-responder'
| 'designated'
| 'consensus'
| 'local-only';
type PermissionVoteOutcome =
| { kind: 'resolved'; resolvedOptionId: string }
| { kind: 'recorded'; votesNeeded: number } // consensus partial
| { kind: 'already_resolved'; resolvedOptionId: string }
| { kind: 'forbidden'; reason: 'designated_mismatch' | 'remote_not_allowed' }
| { kind: 'unknown_request' };
type PermissionResolution =
| { kind: 'option'; optionId: string }
| {
kind: 'cancelled';
reason: 'timeout' | 'session_closed' | 'agent_cancelled';
};Cancel-Sentinel
CANCEL_VOTE_SENTINEL = '__cancelled__'. Die Bridge mappt den Voter {outcome:'cancelled'} vor dem Aufruf von mediator.vote auf dieses Sentinel. Der Mediator leitet das Sentinel vor dem Policy-Dispatch – Voter-Cancel funktioniert unter jeder Policy unabhängig von clientId / Loopback / Membership. Zwei Guards:
bridge.tsweist Wire-Votes zurück, derenoptionId === CANCEL_VOTE_SENTINEList, mitInvalidPermissionOptionError(ein bösartiger Wire-Client darf kein Cancel durch Lügen über eineoptionIdinjizieren können).mediator.requestweist Records zurück, derenallowedOptionIdsdas Sentinel mitCancelSentinelCollisionErrorenthalten (ein Agent, der legitimerweise'__cancelled__'als Options-Label veröffentlicht, darf sich nicht maskieren können).
Dieser bewusste Cross-Policy-Escape ist in permissionMediator.ts dokumentiert, damit ein zukünftiger Maintainer den Bypass nicht versehentlich entfernt.
Pending State
Jede ausstehende Anfrage ist über requestId indiziert und enthält:
policy– zum Zeitpunkt vonrequest()erfasst.record: PermissionRequestRecord(requestId, sessionId, originatorClientId, allowedOptionIds, issuedAtMs).resolve- /reject-Closures.votesAtIssue(nur Consensus) – Snapshot der registriertenclientIdsfür die Session zum Ausstellungszeitpunkt; spätere Votes werden abgelehnt, wenn sie nicht in dieser Menge enthalten sind.tally(nur Consensus) –Map<optionId, Set<clientId>>zur Zählung der Votes pro Option.timeoutHandle– Node-Timeout, aktiviert innerhalb vonrequest()(N1-Invariante).auditTrail[]– Audit-Records pro Vote.
Resolved FIFO
MAX_RESOLVED_PERMISSION_RECORDS = 512. Die Eviction erfolgt FIFO über resolvedOrder.shift() (DeepSeek Review #4335 / 3271627446 – spiegelt PermissionAuditRing wider). Speichert nur {requestId, sessionId, outcome}, sodass 512 Records über normale UI-Reconnect-/Race-Windows hinweg unter 100 KB bleiben.
Workflow
request() (N1-Invariante)
Der Timer wird aktiviert, bevor der Eintrag überhaupt woanders sichtbar ist. Ohne dies würde ein forgetSession, das zwischen pending.set und setTimeout eintrifft, den Eintrag ohne Timeout ausstehend lassen – die sessionbezogene promptQueue der Bridge würde für immer hängen.
vote()-Dispatch
forgetSession()
Wird bei Session-Schließung, Eviction und Bridge-Shutdown aufgerufen. Für jeden ausstehenden Eintrag, dessen record.sessionId === sessionId ist:
- Timeout abbrechen.
- Das ausstehende Promise mit
{kind: 'cancelled', reason: 'session_closed'}auflösen. - Einen Audit-Record anhängen.
- Aus
pendingentfernen.
Der Session-Teardown-Pfad der Bridge ruft forgetSession immer vor dem Channel-Kill-Window auf, damit ausstehende Permissions ihre Session nicht überleben.
State & Lifecycle
policywird pro Anfrage erfasst. Eine Änderung der daemon-weiten Policy (zukünftige Schnittstelle) betrifft keine laufenden Anfragen.votesAtIssue(Consensus) wird zum Zeitpunkt vonrequest()erfasst; Clients, die nach der Anfrage eintreffen, können voten, aber wenn ihreclientIdzum Ausstellungszeitpunkt noch nicht bei der Session registriert war, wird ihr Vote alsdesignated_mismatchabgelehnt. Dies nutzt absichtlich den Mismatch-Grund derdesignated-Policy, um den Vertrag geschlossen zu halten; zukünftige Versionen könnten die Union aufteilen, wenn SDK-Consumer unterscheiden müssen.- Aufgelöste Einträge leben maximal
MAX_RESOLVED_PERMISSION_RECORDS(512) lang in der FIFO. Nach der Eviction gibt ein doppelter Vote auf dieselberequestId{unknown_request}zurück. permission_partial_votefeuert nur fürconsensus. Verlasse dich unter keiner anderen Policy darauf.permission_forbiddenfeuert fürdesignated,consensusundlocal-only– nicht fürfirst-responder.
Dependencies
03-acp-bridge.md– wie die BridgeBridgeClient.requestPermissionmitmediator.requestverdrahtet.10-event-bus.md– wie Partial-Vote- und Forbidden-Frames Clients erreichen.09-event-schema.md– Payload-Verträge fürpermission_*-Events.08-session-lifecycle.md–forgetSession()wird bei jeder Session-Beendigung aufgerufen.02-serve-runtime.md–PermissionAuditRing(512-Einträge-FIFO von Audit-Records).
Configuration
| Quelle | Parameter | Effekt |
|---|---|---|
settings.json | policy.permissionStrategy | Aktive Mediator-Policy. |
settings.json | policy.consensusQuorum | N für Consensus. |
BridgeOptions | permissionPolicy, permissionConsensusQuorum, permissionAudit | Programmatisches Override. |
| Capability-Tag | permission_mediation (immer; modes: ['first-responder', 'designated', 'consensus', 'local-only']) | Build-unterstützte Menge. |
| Capability-Envelope | policy.permission | Aktive Policy, unter der dieser Daemon läuft. |
Wenn policy.permissionStrategy nicht explizit konfiguriert ist, verwendet der Daemon first-responder. designated, consensus und local-only treten nur in Kraft, wenn sie in settings.json gesetzt sind.
Consensus-Quorum: Standardformel und der M=2-Sonderfall
Wenn die consensus-Policy aktiv ist und policy.consensusQuorum nicht gesetzt ist, berechnet der Mediator N = floor(M/2) + 1 über consensusQuorumFor in permissionMediator.ts:
Math.max(1, Math.floor(m / 2) + 1);M (votersAtIssue.size) | Standard-N | Verhalten |
|---|---|---|
| 1 | 1 | Ein Voter löst sofort auf. |
| 2 | 2 | Erfordert einstimmige Zustimmung. |
| 3 | 2 | Mehrheit. |
| 4 | 3 | Mehr als die Hälfte. |
| 5 | 3 | Mehrheit. |
| 6 | 4 | Mehr als die Hälfte. |
Für M = 2 können Split-Votes (A wählt X, B wählt Y) nur durch den anfragebezogenen Permission-Timeout aufgelöst werden: Keine Option erreicht Einstimmigkeit, daher wartet die Anfrage bis permissionResponseTimeoutMs (Standard 5 Min.) und löst sich als {cancelled, timeout} auf. Der Vote-Advance-Pfad loggt dieses Verhalten „Einstimmigkeit bedeutet, dass Split-Votes ein Timeout haben“ auf stderr für Operatoren.
Operatoren, die für M = 2 ein First-Vote-Wins-Verhalten wünschen, können explizit policy.consensusQuorum: 1 setzen. Strengere Konfigurationen, wie das Erfordernis von Einstimmigkeit für M = 4, verwenden dasselbe Feld.
Policy-Validierung zur Boot-Zeit
runQwenServe.validatePolicyConfig(policyConfig) (packages/cli/src/serve/run-qwen-serve.ts) validiert das zusammengeführte policy.* aus settings.json beim Boot und wirft InvalidPolicyConfigError bei Operator-Fehlern:
policy.permissionStrategyist gesetzt, aber nicht in den vier unterstützten Modi. Die gültige Menge wird zur Laufzeit ausSERVE_CAPABILITY_REGISTRY.permission_mediation.modesabgeleitet, der Single Source of Truth für die Capability-Ankündigung.policy.consensusQuorumist gesetzt, aber keine positive Ganzzahl.
Es gibt zudem eine weiche stderr-Warnung, wenn consensusQuorum gesetzt ist, während permissionStrategy !== 'consensus' gilt; andernfalls würde das Override unter Non-Consensus-Policies stillschweigend ignoriert.
InvalidPolicyConfigError wird für instanceof-Tests exportiert. runQwenServe nutzt es, um Operator-Fehlkonfigurationen, die als expliziter Boot-Fehler erneut geworfen werden, von I/O-Fehlern beim Lesen der Einstellungen zu unterscheiden, die auf Standardwerte zurückfallen.
Sicherheitshinweis: v1-Client-Identität wird selbst angegeben
Die X-Qwen-Client-Id wird vom HTTP-Client bereitgestellt. In v1 validiert der Daemon das Format ([A-Za-z0-9._:-]{1,128}) und verfolgt angehängte Client-IDs in clientIds, führt aber keinen Proof-of-Possession durch. Jeder Client, der originatorClientId in SSE beobachten kann, kann sich mit derselben ID registrieren und diesen Urheber in späteren Anfragen imitieren.
Policy-Auswirkungen:
first-responderist nicht betroffen, da es nicht von der Identität abhängt.designatedkann von einem Remote-Client gespooft werden, deroriginatorClientIdwiederverwendet.consensusprüft gegen denvotersAtIssue-Snapshot zum Ausstellungszeitpunkt; wenn eine gespoofte ID zum Zeitpunkt der Anfrage bereits angehängt ist, kann sie voten.local-onlyist immun gegen ID-Spoofing, dafromLoopback: booleanvom Daemon aus der Remote-Adresse der Verbindung gestempelt und nicht vom Client bereitgestellt wird.
Ein zukünftiger Pair-Token-Mechanismus wird ein sessionbezogenes Secret aus POST /session ausgeben und es für designated- / consensus-Votes erfordern. Dieser Mechanismus existiert in v1 nicht.
Verbindungsübergreifendes Vote-Routing
Vote-Zustellungspfade
Permission-Votes können den Bridge-Mediator über zwei unabhängige Transportpfade erreichen:
-
ACP-Transport (Same-Connection-Antwort): Das
permission_request-Bridge-Event wird alssession/request_permission-JSON-RPC-Request an den sessionbezogenen SSE/WS-Stream der besitzenden Verbindung geliefert. Der Client antwortet mit einer JSON-RPC-Response auf derselben Verbindung. DieresolveClientResponsedes Dispatchers mappt die verbindungslokale JSON-RPC-ID zurück auf dierequestIdder Bridge und ruftbridge.respondToSessionPermissionauf. -
REST-API (verbindungsübergreifend): Jeder HTTP-Client – einschließlich Clients auf einer anderen ACP-Verbindung oder ganz ohne ACP-Verbindung – kann über
POST /session/:id/permission/:requestIdvoten. Die Legacy-RoutePOST /permission/:requestId(keine Session in der URL) verwendetpeekSessionFor(requestId), um die Session aufzulösen, bevor sie an denselbenrespondToSessionPermission-Pfad delegiert.
Verbindungslokale Permission-Request-IDs
Der ACP-Transport verwendet ein zweistufiges ID-Schema, um zwischen Wire und Bridge zu mappen:
| Layer | ID-Format | Scope | Zweck |
|---|---|---|---|
| JSON-RPC-Message-ID | _qwen_perm_N (String, monoton pro Verbindung) | Verbindungslokal | Korreliert das JSON-RPC-Request→Response-Paar auf dem Session-Stream. |
| Bridge-Request-ID | Opaque Zeichenfolge (UUID generiert vom Agent/Mediator) | Daemon-global | Identifiziert die Permission-Anfrage über alle Routen und die pending/resolved-Maps des Mediators hinweg. |
Die Bridge-Request-ID wird durch die _meta-Vendor-Extension gereicht, damit der Client sie beim Voten über den REST-Pfad einschließen kann:
{
"method": "session/request_permission",
"id": "_qwen_perm_3",
"params": {
"sessionId": "<session-id>",
"toolCall": { "name": "shell" },
"options": [{ "optionId": "allow", "name": "Allow" }],
"_meta": { "qwen": { "requestId": "<bridge-request-id>" } }
}
}Die Verbindung speichert das Mapping in conn.pending: Map<jsonRpcId, PendingClientRequest>, wobei PendingClientRequest.bridgeRequestId die ID auf Bridge-Ebene ist.
Vote-Autorisierungsregeln
respondToSessionPermission(sessionId, requestId, response, context) wendet die folgenden Prüfungen der Reihe nach an:
-
Session-Existenz – die über
sessionIdadressierte Session muss live sein (byId.has(sessionId)). AndernfallsSessionNotFoundError. -
Cross-Session-Ablehnung –
peekSessionFor(requestId)löst die Session auf, zu der die Anfrage tatsächlich gehört. Wenn sie zu einer anderen Session gehört, wird der Vote abgelehnt (gibtfalse/ 404 zurück), ohne Session-Mitgliedschaftsinformationen preiszugeben. -
Unknown-Request-Guard – wenn
peekSessionForundefinedzurückgibt (Anfrage hat Timeout, LRU-evictet oder existierte nie), wird der Vote vor jederclientId-Validierung abgelehnt (gibtfalse/ 404 zurück). Dies verhindert einen Oracle-Angriff: Ohne dies könnte ein Probe-Request mit einer fabriziertenclientIdzwischen „Session hat diesen Client“ (besteht Validierung → 404) und „Client unbekannt“ (InvalidClientIdError→ 400) unterscheiden. -
Client-Identitätsvalidierung –
resolveTrustedClientId(entry, context?.clientId)verifiziert, dass die bereitgestellteX-Qwen-Client-Id(REST) oder die von der Bridge gestempelteclientId(ACP) in derclientIds-Map der Session registriert ist. Anonyme Votes (clientId === undefined) werden durchgelassen – der Policy-Dispatch verarbeitet sie. Nicht registrierte IDs werfenInvalidClientIdError(durch Route-Handler auf 400 gemappt). -
Cancel-Sentinel-Erzwingung – ein Wire-Vote von
{ outcome: "selected", optionId: "__cancelled__" }wird mitInvalidPermissionOptionErrorabgelehnt, um Sentinel-Injektion zu verhindern. -
Mediator-
vote()-Dispatch – der validierte Vote wird anpermissionMediator.vote(...)weitergeleitet, was die aktive Policy anwendet (siehe Workflow →vote()-Dispatch).
Loopback-Auswertung
Das fromLoopback-Bit wird pro Anfrage ausgewertet, nicht pro Verbindung:
- ACP-Transport:
reqLoopbackwird auf der HTTP-Ebene aus derreq.socket.remoteAddressauf Kernel-Ebene der POST-Anfrage gesetzt und andispatcher.handle(conn, msg, sessionHeader, isLoopbackReq(req))übergeben. Das bedeutet, dass eine POST-Anfrage für ein Permission-Vote, die von einem anderen Peer als dieinitialize-Anfrage eingeht, eine eigene Loopback-Prüfung durchläuft. - REST-API:
detectFromLoopback(req)wertet dieselbe Remote-Adresse auf Socket-Ebene aus.
Keiner dieser Pfade leitet den Loopback-Status von fälschbaren Headern (X-Forwarded-For, Forwarded usw.) ab.
Antwortformat für ACP-Transport-Votes
Ein Client antwortet auf session/request_permission mit einer Standard-JSON-RPC-Antwort:
Akzeptieren (eine Option auswählen):
{
"jsonrpc": "2.0",
"id": "_qwen_perm_3",
"result": {
"outcome": { "outcome": "selected", "optionId": "allow" }
}
}Abbrechen:
{
"jsonrpc": "2.0",
"id": "_qwen_perm_3",
"result": {
"outcome": { "outcome": "cancelled" }
}
}Fehlerantwort (vom Dispatcher auf “Abbrechen” gemappt):
{
"jsonrpc": "2.0",
"id": "_qwen_perm_3",
"error": { "code": -32000, "message": "user declined" }
}Fehlerbehandlung in resolveClientResponse
Wenn bridge.respondToSessionPermission einen Fehler wirft (z. B. bei einem fehlerhaften Vote-Body), greift der Dispatcher auf ein explizites Abbrechen (cancelAbandonedPermission) zurück, damit der Mediator niemals dauerhaft blockiert bleibt. Wenn sowohl das Vote als auch das Abbrechen einen Fehler werfen (Doppelfehler), wird der pending-Eintrag beibehalten, damit das spätere Teardown der Verbindung (abandonPendingForSession) es erneut versuchen kann.
Einschränkungen & bekannte Limits
- Cancel-Sentinel-Routen VOR dem Policy-Dispatch sind konstruktionsbedingt so ausgelegt – ein
local-only-Daemon und einconsensus-Daemon können beide von jedem Voter abgebrochen werden, der{outcome: 'cancelled'}sendet. Dies ist inpermissionMediator.tsdokumentiert und stellt den Abbruchpfad auf Agentenseite dar. designatedundconsensusüberladendesignated_mismatchinPermissionVoteOutcome. Der Mediator gibt separate Audit-Datensätze aus, aber das Übertragungsformat ist einheitlich. Zukünftige Protokollversionen könnten die Union aufteilen.- Anonyme Voter (keine
X-Qwen-Client-Id) werden nur unterfirst-responderundlocal-only(Loopback) akzeptiert;designatedundconsensuslehnen sie ab. - Cross-Policy-Escape-Hatch bedeutet, dass Abbrüche nicht durch Policies gefiltert werden können. Wenn ein Deployment policy-gefilterte Abbrüche benötigt, erfordert dies eine zukünftige Änderung des API-Vertrags – versuche nicht, dies durch Prüfungen auf Routen-Ebene zu kaschieren.
votesAtIssue-Snapshot-Semantik bedeutet, dass bei einem Consensus-Deployment mit einem fluktuierenden Client-Bestand legitime Clients abgelehnt werden können, weil sie sich nach dem Absenden der Anfrage verbunden haben. Operatoren sollten Collaborator-Client-IDs vorregistrieren, bevor sie Change-Review-Prompts ausgeben.
Referenzen
packages/acp-bridge/src/permission.ts(festgeschriebener Contract)packages/acp-bridge/src/permissionMediator.ts(F3-Mediator-Implementierung)packages/acp-bridge/src/bridgeClient.ts(verwendet strukturelles Sub-Typing fürPermissionMediator)packages/acp-bridge/src/bridge.ts(respondToSessionPermission— Vote-Routing und Autorisierung)packages/acp-bridge/src/bridgeErrors.ts(CancelSentinelCollisionError,InvalidPermissionOptionError,PermissionForbiddenError,InvalidClientIdError)packages/cli/src/serve/acp-http/dispatch.ts(resolveClientResponse— ACP-Transport-Vote-Pfad)packages/cli/src/serve/acp-http/connection-registry.ts(AcpConnection.pending— verbindungsspezifisches Request-Mapping)packages/cli/src/serve/routes/permission.ts(REST-Vote-Routen)packages/cli/src/serve/permission-audit.ts(Audit-Ring + Publisher)- Issue: #4175 F3-Serie.