-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(chat): handle gateway sudo.request / secret.request via hardened modal #606
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fcc44c7
d6a0308
c07534e
0e47d18
230ef6d
d88fcd3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import type { BrowserWindow } from "electron"; | ||
| import { showPasswordDialog } from "./askpass"; | ||
|
|
||
| /** | ||
| * Mid-turn gateway credential prompts (`sudo.request` / `secret.request`). | ||
| * | ||
| * Unlike `clarify.request` — which renders an inline card in the chat | ||
| * transcript — a sudo password or a secret value is sensitive and must NEVER | ||
| * land in scrollback. So these reuse the installer's hardened askpass modal | ||
| * (`showPasswordDialog`): CSP-locked `default-src 'none'`, sandboxed, ephemeral | ||
| * data-URL, the value never persisted. | ||
| * | ||
| * Gateway protocol (NousResearch/hermes-agent, tui_gateway/server.py), keyed by | ||
| * request_id: | ||
| * sudo.request {} -> sudo.respond { request_id, password } | ||
| * secret.request { prompt, env_var, ... } -> secret.respond { request_id, value } | ||
| * An empty answer is a safe "skip": the gateway treats secret.request as | ||
| * skipped and lets a terminal sudo prompt fail cleanly, so cancel maps to "". | ||
| */ | ||
|
|
||
| let parentWindowGetter: () => BrowserWindow | null = () => null; | ||
|
|
||
| /** Wire the provider that returns the window to parent the modal to. Called | ||
| * once from index.ts after the main window is created. */ | ||
| export function setGatewayPromptParent( | ||
| getter: () => BrowserWindow | null, | ||
| ): void { | ||
| parentWindowGetter = getter; | ||
| } | ||
|
|
||
| /** | ||
| * Prompt for the sudo password. Resolves with the password, or "" if the user | ||
| * cancels (safe skip — terminal sudo then fails cleanly rather than hanging). | ||
| */ | ||
| export async function promptSudoPassword(): Promise<string> { | ||
| const parent = parentWindowGetter(); | ||
| const value = await showPasswordDialog( | ||
| parent, | ||
| "An agent command needs administrator (sudo) access to continue. " + | ||
| "Your password is sent only to the local sudo prompt and is never stored.", | ||
| { | ||
| title: "Administrator Password Required", | ||
| heading: "Hermes needs your sudo password", | ||
| }, | ||
| ); | ||
| return value ?? ""; | ||
| } | ||
|
|
||
| /** | ||
| * Prompt for a named secret the agent requested (e.g. an API key it needs to | ||
| * store). Resolves with the value, or "" if the user cancels (safe skip — the | ||
| * gateway records the secret as skipped). | ||
| */ | ||
| export async function promptSecretValue( | ||
| envVar: string, | ||
| prompt: string, | ||
| ): Promise<string> { | ||
| const parent = parentWindowGetter(); | ||
| const detail = | ||
| (prompt && prompt.trim()) || | ||
| `The agent is requesting a value for ${envVar || "a secret"}.`; | ||
| const value = await showPasswordDialog(parent, detail, { | ||
| title: "Secret Required", | ||
| heading: envVar | ||
| ? `Hermes needs a value for ${envVar}` | ||
| : "Hermes needs a secret value", | ||
| }); | ||
| return value ?? ""; | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -45,6 +45,8 @@ import { | |||||||||||||||||||||||||||||||||||
| getActiveProfileNameSync, | ||||||||||||||||||||||||||||||||||||
| } from "./utils"; | ||||||||||||||||||||||||||||||||||||
| import { getProfilePort } from "./gateway-ports"; | ||||||||||||||||||||||||||||||||||||
| import { promptSudoPassword, promptSecretValue } from "./gatewayPrompt"; | ||||||||||||||||||||||||||||||||||||
| import { getSecret } from "./secrets"; | ||||||||||||||||||||||||||||||||||||
| import { readModels } from "./models"; | ||||||||||||||||||||||||||||||||||||
| import { providerListSafe } from "./secrets"; | ||||||||||||||||||||||||||||||||||||
| import { HIDDEN_SUBPROCESS_OPTIONS } from "./process-options"; | ||||||||||||||||||||||||||||||||||||
|
|
@@ -1984,14 +1986,63 @@ async function sendMessageViaTuiGateway( | |||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| if (event.type === "sudo.request" || event.type === "secret.request") { | ||||||||||||||||||||||||||||||||||||
| // Out of scope for the inline-clarify change: a desktop sudo/secret prompt | ||||||||||||||||||||||||||||||||||||
| // carries its own security-review surface and is a deliberate follow-up. | ||||||||||||||||||||||||||||||||||||
| void client | ||||||||||||||||||||||||||||||||||||
| .request("session.interrupt", { session_id: activeSessionId }, 5_000) | ||||||||||||||||||||||||||||||||||||
| .catch(() => undefined); | ||||||||||||||||||||||||||||||||||||
| finish( | ||||||||||||||||||||||||||||||||||||
| `Hermes requested ${event.type.replace(".request", "")} input, but Hermes One does not yet expose that gateway dialog.`, | ||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||
| const isSudo = event.type === "sudo.request"; | ||||||||||||||||||||||||||||||||||||
| const requestId = | ||||||||||||||||||||||||||||||||||||
| typeof event.payload?.request_id === "string" | ||||||||||||||||||||||||||||||||||||
| ? event.payload.request_id | ||||||||||||||||||||||||||||||||||||
| : ""; | ||||||||||||||||||||||||||||||||||||
| if (!requestId) { | ||||||||||||||||||||||||||||||||||||
| void client | ||||||||||||||||||||||||||||||||||||
| .request("session.interrupt", { session_id: activeSessionId }, 5_000) | ||||||||||||||||||||||||||||||||||||
| .catch(() => undefined); | ||||||||||||||||||||||||||||||||||||
| finish( | ||||||||||||||||||||||||||||||||||||
| `Hermes requested ${event.type.replace(".request", "")} input, but the gateway provided no request_id to answer.`, | ||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| // A sudo password / secret value is sensitive — collect it in the | ||||||||||||||||||||||||||||||||||||
| // hardened askpass modal (never the chat transcript) and forward it to | ||||||||||||||||||||||||||||||||||||
| // the gateway. Cancel maps to "" (a safe skip the gateway handles). | ||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||
| // For secret.request: try the configured security provider first. If the | ||||||||||||||||||||||||||||||||||||
| // vault already holds the key, answer silently without prompting the user. | ||||||||||||||||||||||||||||||||||||
| const payload = event.payload as | ||||||||||||||||||||||||||||||||||||
| | { prompt?: string; env_var?: string } | ||||||||||||||||||||||||||||||||||||
| | undefined; | ||||||||||||||||||||||||||||||||||||
| const envVar = String(payload?.env_var ?? ""); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // Vault-first resolution for secret.request: attempt a provider lookup | ||||||||||||||||||||||||||||||||||||
| // before falling back to the interactive modal. sudo.request always needs | ||||||||||||||||||||||||||||||||||||
| // an interactive password — no vault lookup applies. | ||||||||||||||||||||||||||||||||||||
| const vaultValue = | ||||||||||||||||||||||||||||||||||||
| !isSudo && envVar ? getSecret(envVar, profile) : null; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const collect: Promise<string> = | ||||||||||||||||||||||||||||||||||||
| vaultValue != null | ||||||||||||||||||||||||||||||||||||
| ? Promise.resolve(vaultValue) | ||||||||||||||||||||||||||||||||||||
| : isSudo | ||||||||||||||||||||||||||||||||||||
| ? promptSudoPassword() | ||||||||||||||||||||||||||||||||||||
| : promptSecretValue(envVar, String(payload?.prompt ?? "")); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| void collect | ||||||||||||||||||||||||||||||||||||
| .then((answer) => { | ||||||||||||||||||||||||||||||||||||
| if (finished) return; // turn was cancelled while modal was open | ||||||||||||||||||||||||||||||||||||
| const method = isSudo ? "sudo.respond" : "secret.respond"; | ||||||||||||||||||||||||||||||||||||
| const params = isSudo | ||||||||||||||||||||||||||||||||||||
| ? { request_id: requestId, password: answer } | ||||||||||||||||||||||||||||||||||||
| : { request_id: requestId, value: answer }; | ||||||||||||||||||||||||||||||||||||
| return client.request(method, params, 300_000); | ||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+2027
to
+2035
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||||||||
| .catch((error) => { | ||||||||||||||||||||||||||||||||||||
| const message = | ||||||||||||||||||||||||||||||||||||
| error instanceof Error ? error.message : String(error); | ||||||||||||||||||||||||||||||||||||
| if (!hasGatewayOutput) { | ||||||||||||||||||||||||||||||||||||
| startApiFallback(message); | ||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| finish(message); | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
envVarused verbatim in the modal heading without sanitisation.envVarcomes directly fromevent.payload.env_varin the gateway event stream — an untrusted, agent-controlled string. It flows into theheadingoption and is rendered as<div class="title">${safeHeading}</div>inbuildDialogHtml, which does HTML-escape it, so the XSS risk in the rendered DOM is mitigated. However, the raw value also appears in thetitleoption, which is used as the OS-level window title (win.title), where HTML escaping is irrelevant. A length cap and simple printable-chars validation onenvVarwould close the spoofing surface entirely.