Skip to content

feat(chat): handle gateway sudo.request / secret.request via hardened modal#606

Draft
0xr00tf3rr3t wants to merge 3 commits into
fathah:mainfrom
0xr00tf3rr3t:feat/inline-sudo-secret
Draft

feat(chat): handle gateway sudo.request / secret.request via hardened modal#606
0xr00tf3rr3t wants to merge 3 commits into
fathah:mainfrom
0xr00tf3rr3t:feat/inline-sudo-secret

Conversation

@0xr00tf3rr3t

Copy link
Copy Markdown
Contributor

Summary

  • handle the gateway's sudo.request / secret.request prompts instead of dead-ending the turn with "Hermes One does not yet expose that gateway dialog" (the same dead-end feat(chat): render gateway clarify.request as an inline card #604 removed for clarify.request)
  • a sudo password / secret value is sensitive, so — unlike the inline clarify card — it is collected in the app's existing hardened askpass modal (CSP default-src 'none', script-src 'none', sandboxed, contextIsolation, ephemeral data-URL) and never rendered into the chat transcript or persisted
  • showPasswordDialog is parameterized with title / heading (defaults unchanged, so the installer's existing call is untouched) and exported; gatewayPrompt.ts wraps it as promptSudoPassword() / promptSecretValue(envVar, prompt)
  • answers are forwarded with the gateway's exact shapes — sudo.respond { request_id, password } and secret.respond { request_id, value } — and Cancel maps to an empty answer, a safe skip the gateway already handles (secret.request{ skipped: true }; sudo -S -p '' lets the terminal prompt fail cleanly), so the turn never wedges

Why a modal, not an inline card

clarify.request (#604) is a non-sensitive question → inline transcript card. A sudo password or secret must never land in scrollback, so this reuses the installer's already-hardened askpass dialog rather than introducing a new surface. Same trust boundary the app already uses to collect the installer's sudo password.

Testing

  • npm run typecheck
  • npm run build
  • npm run lint -- --quiet (no new problems from this change; one pre-existing error in src/main/ssh-remote.ts is untouched here)
  • git diff --check
  • npm test — 1052 passing (+4 new askpass-security.test.ts cases: modal reuse, no-log/no-persist, exact respond shapes, cancel→skip)

Note

Stacked on #604 (inline clarify card) — that branch is its base, so the diff shows the clarify commits too until #604 merges. The only new commit here is the top one (feat(chat): handle gateway sudo.request / secret.request via hardened modal). Happy to rebase onto main once #604 lands.

The stock build dead-ends a mid-turn clarify.request — it interrupts the session
and finishes with "Hermes One does not yet expose that gateway dialog," so the
agent's clarifying questions can never be answered from the desktop.

Render clarify inline in the chat transcript instead, following the existing
streaming-event pattern (tool_event / reasoning):

- main/hermes.ts: a request_id-keyed pending-clarify registry; the clarify.request
  handler registers a resolver (closure over the gateway client) and surfaces the
  question via a new onClarify callback instead of interrupting. The resolver
  forwards the user's answer to clarify.respond. Stale resolvers are cleared on
  turn end. sudo.request/secret.request are left unchanged (deliberate follow-up).
- main/index.ts: bridge onClarify -> safeSend("chat-clarify-request") and a
  clarify-respond IPC handler that resolves the pending request.
- preload: onClarifyRequest (main->renderer) + respondClarify (renderer->main).
- renderer: a ClarifyMessage variant in the ChatMessage union; useChatIPC appends
  it (idempotent on request_id); MessageList dispatches a new ClarifyCard that
  renders choice buttons / open-ended textarea / skip, answers via respondClarify,
  and flips to a read-only resolved state. Skip sends an empty answer so the agent
  proceeds autonomously.
- i18n (en), themed CSS, and ClarifyCard.test.tsx (5 cases).

Tests: 1044 pass (+5). typecheck + lint + production build all green.
…ery failures

Addresses review feedback on PR fathah#604:

1. Transcript ordering (sessionHistory.ts): a clarify card is renderer-only and
   has no reconciliationKey, so reconcileStreamedWithDb flushed it to the suffix
   — rendering it BELOW any agent content streamed after the user answered, the
   reverse of what they saw live. Add repositionClarifyCards(): a pure post-pass
   that re-anchors each card immediately after its streamed predecessor, with a
   safety net so a card is never dropped if its predecessor was deduped.

2. Failed delivery (ClarifyCard.tsx): submit() previously called onResolved in a
   finally block, marking the card answered even when respondClarify rejected or
   returned false (no pending request — e.g. the turn already ended). Now resolve
   only on confirmed delivery; on failure surface an inline error and keep the
   controls live for a retry.

Tests: +2 reconcile ordering cases (mid-stream + leading card), +2 ClarifyCard
failure-path cases (returns-false, rejects). Suite 1048 pass. typecheck + build
green. Diffs kept surgical (no formatting sweep of existing lines).
… modal

The stock build dead-ends sudo.request / secret.request the same way clarify
used to — it interrupts the turn with "Hermes One does not yet expose that
gateway dialog," so any agent command needing sudo or a secret value can't
proceed from the desktop.

Unlike clarify (an inline transcript card), a sudo password / secret value is
sensitive and must never land in chat scrollback. So this reuses the installer's
existing hardened askpass modal (showPasswordDialog): CSP-locked
default-src 'none', sandboxed, contextIsolation, ephemeral data-URL, value never
persisted. showPasswordDialog is parameterized with title/heading (defaults
unchanged for the installer caller) and exported.

- gatewayPrompt.ts: promptSudoPassword() / promptSecretValue(envVar, prompt)
  wrap the modal; a new setGatewayPromptParent() lets index.ts hand in the main
  window. Cancel maps to "" — a safe skip the gateway already handles
  (secret.request -> {skipped:true}; sudo lets terminal sudo fail cleanly).
- hermes.ts: the sudo/secret stream handler collects via the modal and forwards
  with the gateway's exact shapes — sudo.respond {request_id, password} and
  secret.respond {request_id, value} (verified against tui_gateway/server.py).
- askpass-security.test.ts: +4 cases pinning modal reuse, no-log/no-persist,
  the exact respond shapes, and cancel->skip.

Stacks on the inline-clarify PR (fathah#604). Suite 1052 pass (+4); typecheck, lint
(--quiet), build all green.
@0xr00tf3rr3t 0xr00tf3rr3t marked this pull request as draft June 9, 2026 02:19
@greptile-apps

greptile-apps Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR wires up the gateway's sudo.request and secret.request events by collecting credentials through the existing hardened askpass modal (showPasswordDialog), keeping sensitive values out of the chat transcript entirely. It also implements clarify.request as an inline card in the transcript. The showPasswordDialog function is exported and parameterised with title/heading while preserving the installer's existing defaults.

  • gatewayPrompt.ts wraps the askpass modal for both sudo and secret prompts; answers are forwarded to the gateway via the correct sudo.respond/secret.respond shapes, and cancel maps to \"\" (a safe skip).
  • ClarifyCard.tsx renders mid-turn clarifying questions inline; choice buttons, open-ended textarea, skip, and resolved read-only state are all handled, with error feedback if IPC delivery fails.
  • repositionClarifyCards in sessionHistory.ts re-anchors renderer-only clarify cards to their original chronological position after reconcileStreamedWithDb, preventing them from appearing below post-answer agent content.

Confidence Score: 3/5

Safe to merge after adding a finished guard in the sudo/secret .then() callback; without it a cancelled turn can still dispatch credentials to a dead gateway session.

The sudo/secret collect-and-forward chain fires unconditionally once the modal resolves, even if the turn was cancelled or errored while the modal was open. The finished flag is in scope and used everywhere else in the same function, but it is not checked at the top of the .then() callback. This means a user who dismisses or submits the askpass modal after aborting their turn will have their credential forwarded to a session the gateway is no longer expecting, and the 300 s client.request timeout will hang until the gateway rejects.

src/main/hermes.ts — the sudo/secret event handler around the void collect.then() block

Important Files Changed

Filename Overview
src/main/hermes.ts Adds clarify/sudo/secret event handlers; sudo/secret path lacks a finished guard before forwarding credentials to the gateway, so a turn cancellation while the modal is open can still dispatch sudo.respond/secret.respond on a dead session
src/main/gatewayPrompt.ts New module cleanly wraps showPasswordDialog for sudo/secret prompts; heading/prompt are HTML-escaped by buildDialogHtml; cancel correctly maps to ""
src/main/askpass.ts showPasswordDialog exported and parameterized with title/heading; buildDialogHtml updated to escape both prompt and heading; backward-compatible defaults preserved
src/main/index.ts Wires setGatewayPromptParent and the clarify-respond IPC handler correctly; payload coercion with ?? "" is safe
src/renderer/src/screens/Chat/ClarifyCard.tsx New inline clarify component with choice buttons / textarea / skip; error/retry logic and resolved read-only state handled correctly
src/renderer/src/screens/Chat/sessionHistory.ts repositionClarifyCards correctly re-anchors renderer-only clarify cards after reconcile; leading-card and missing-predecessor safety nets in place
src/renderer/src/screens/Chat/hooks/useChatIPC.ts onClarifyRequest listener correctly deduplicates by requestId and cleans up in the useEffect teardown
tests/askpass-security.test.ts New static-analysis tests verify modal reuse, no-log/no-persist, exact respond shapes, and cancel→skip mapping

Reviews (1): Last reviewed commit: "feat(chat): handle gateway sudo.request ..." | Re-trigger Greptile

Comment thread src/main/hermes.ts
Comment on lines +1976 to +1983
void collect
.then((answer) => {
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);
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing finished guard before forwarding credentials to the gateway. If cancel() or finish() is called while the askpass modal is still open (e.g. the user aborts the chat turn), finished becomes true but the modal stays open with no way to dismiss it programmatically. When the user eventually submits the modal, collect resolves and the .then() fires unconditionally — forwarding the sudo password or secret value to the gateway on a session that has already been torn down. The clarify path avoids this by clearing pendingClarify in finish()/cancel(), making stale responses a no-op; the same protection is missing here.

Suggested change
void collect
.then((answer) => {
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);
})
void collect
.then((answer) => {
if (finished) return;
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 thread src/main/gatewayPrompt.ts
Comment on lines +54 to +69
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 ?? "";
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 envVar used verbatim in the modal heading without sanitisation. envVar comes directly from event.payload.env_var in the gateway event stream — an untrusted, agent-controlled string. It flows into the heading option and is rendered as <div class="title">${safeHeading}</div> in buildDialogHtml, which does HTML-escape it, so the XSS risk in the rendered DOM is mitigated. However, the raw value also appears in the title option, which is used as the OS-level window title (win.title), where HTML escaping is irrelevant. A length cap and simple printable-chars validation on envVar would close the spoofing surface entirely.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant