diff --git a/surfaces/quickshell/TRUST-BOUNDARY-MATRIX.md b/surfaces/quickshell/TRUST-BOUNDARY-MATRIX.md index ddf9468..9ba502e 100644 --- a/surfaces/quickshell/TRUST-BOUNDARY-MATRIX.md +++ b/surfaces/quickshell/TRUST-BOUNDARY-MATRIX.md @@ -35,6 +35,7 @@ is not — if an operation is not in this matrix, it is not gated. | Agent conversation | Souveraine IPC | ambient | Server auth token | enforced | | Agent send with personal context | Souveraine IPC | personal | !screenLocked && !screenLockSecure | enforced via LockContentPolicy | | Agent output on lock surface | Lock surface | ambient | LockContentPolicy.allowsOnLock() | enforced | +| In-flight agent response on lock | Lock surface | personal | Ai.qml redacts on screenLocked | enforced | | Media metadata on lock surface | Lock surface | ambient (if opted) | LockContentPolicy.mediaMetadataAmbient | enforced | | Media transport controls | Lock surface | ambient | LockContentPolicy.mediaControlsVisible | enforced | | Lock screen power actions | Lock surface | ambient | Config.lock.security.requirePasswordToPower | enforced | @@ -48,7 +49,7 @@ is not — if an operation is not in this matrix, it is not gated. | Agent delete/push operations | Souveraine IPC | stepUp | StepUpAuth built but minTier metadata not wired | | Agent physical access | Souveraine IPC | stepUp | StepUpAuth built but minTier metadata not wired | | Break-glass override | Emergency | scoped grant | Not implemented | -| Boot-time IPC audit | Shell startup | ambient | Not implemented | +| Boot-time IPC audit | Shell startup | ambient | Done (log in Session.qml) | ## Notes diff --git a/surfaces/quickshell/services/Ai.qml b/surfaces/quickshell/services/Ai.qml index fcba886..fb87912 100644 --- a/surfaces/quickshell/services/Ai.qml +++ b/surfaces/quickshell/services/Ai.qml @@ -119,6 +119,31 @@ Singleton { } } + // ── Lock-time response redaction ───────────────────────────────────── + // When the session locks during a personal-tier streaming response, + // the in-flight content must be withheld immediately. Only ambient + // output remains visible on the lock surface. This is the enforcement + // side of SESSION-TRUST-ARCHITECTURE.md's "A lock during personal + // agent output hides it" requirement. + // + // The streaming message is replaced with a redaction placeholder. The + // raw content is preserved in the message's rawContent so it can be + // shown again after unlock (the message stays in history), but the + // displayed content is cleared. + Connections { + target: GlobalStates + function onScreenLockedChanged() { + if (!GlobalStates.screenLocked) return; + if (!root.streamingMessage) return; + // Lock fired mid-stream. Redact the displayed content. + // rawContent preserves the actual response for post-unlock. + if (root.streamingMessage.content.length > 0) { + root.streamingMessage.content = Translation.tr("[content hidden until unlock]"); + console.log("[ai] lock fired during stream — redacted personal output"); + } + } + } + // ── Streaming message shaping ──────────────────────────────────────── property AiMessageData streamingMessage property bool inThinkBlock: false @@ -306,11 +331,37 @@ Singleton { function sendUserMessage(message) { if (message.length === 0) return; root.addMessage(message, "user"); - if (!Souveraine.send(message)) { + const result = Souveraine.send(message); + if (result === "step-up") { + // Step-up auth required. Trigger the PAM flow; on success, + // retry the send. The user message is already in the chat + // history, so we don't add it again. + if (typeof StepUpAuth !== "undefined") { + StepUpAuth.requestAuth("send", function(granted) { + if (granted) { + // Remove the "auth required" indicator if one was + // added, and retry. The queued text was not consumed + // by Souveraine, so we can send it again. + Souveraine.send(message); + // Re-create the streaming message for the response. + root._startStreaming(); + } else { + root.addMessage(Translation.tr("Authentication required to send."), root.interfaceRole); + } + }); + } + return; + } + if (!result) { root.addMessage(Translation.tr("Souveraine server unreachable at %1 — start it with `souveraine server`").arg(Souveraine.serverBase), root.interfaceRole); return; } - /* Streaming assistant message; filled by handleStreamEvent */ + root._startStreaming(); + } + + // Set up the streaming assistant message container. Called after a + // successful send (or after a step-up auth retry succeeds). + function _startStreaming() { root.inThinkBlock = false; root.streamingMessage = root.aiMessageComponent.createObject(root, { "role": "assistant", diff --git a/surfaces/quickshell/services/Souveraine.qml b/surfaces/quickshell/services/Souveraine.qml index 9ee73c3..443c1d1 100644 --- a/surfaces/quickshell/services/Souveraine.qml +++ b/surfaces/quickshell/services/Souveraine.qml @@ -56,6 +56,9 @@ Singleton { // Emitted after a server-owned conversation has been selected and its // persisted transcript loaded for the active surface. signal conversationResumed(string agentId, string conversationId, var messages) + // Emitted when a send is blocked by step-up auth. The UI should call + // StepUpAuth.requestAuth("send", callback) and retry on success. + signal stepUpRequired(string actionFamily, string queuedText) // ── Agent inventory ────────────────────────────────────────────────── Process { @@ -274,10 +277,20 @@ Singleton { property string _queuedText: "" /* Send a user message with ambient context. Returns false if the - server is down or no agent is selected. */ + server is down or no agent is selected. Returns "step-up" if + step-up auth is required but no valid grant exists — the caller + should trigger StepUpAuth.requestAuth("send") and retry. */ function send(text) { if (!root.serverUp || root.currentAgentId.length === 0) return false; if (text.length === 0) return false; + // Step-up gate: if enabled and no valid send grant exists, block + // the send and emit a signal so the UI can trigger auth + retry. + if (Config.options?.lock?.stepUp?.enabled + && typeof StepUpAuth !== "undefined" + && !StepUpAuth.isGranted("send")) { + root.stepUpRequired("send", text); + return "step-up"; + } root._queuedText = text; if (root.ambientEnabled) { cursorProc.running = true; // chain continues in onExited