diff --git a/PROTOCOL.md b/PROTOCOL.md index 302ea8e..3f01c88 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -47,13 +47,14 @@ Fields: | `initialValues` | `array` | No | Default selected values for multiselect | | `active` | `string` | No | Label for "true" in confirm (default: "Yes") | | `inactive` | `string` | No | Label for "false" in confirm (default: "No") | +| `sensitive` | `boolean` | No | Marks prompt as sensitive. Sensitive values must not be sent in OSC `resolve` payloads. | ### Resolve Emitted in two scenarios: 1. **By the terminal host** into PTY stdin when the user interacts with the host's native UI -2. **By the application** to stdout when the TUI prompt resolves normally +2. **By the application** to stdout when the TUI prompt resolves normally (except `sensitive: true` prompts) ``` ESC ] 7770 ; BEL @@ -92,8 +93,8 @@ The reference implementation provides higher-level prompt types that map to exis | Variant | Wire Type | Description | |---|---|---| -| `password` | `input` | Text input with masked TUI display. The OSC payload is identical to `input`. The masking is purely a TUI rendering concern. | -| `number` | `input` | Numeric input with validation, min/max bounds, and up/down stepping. The OSC payload is identical to `input`. Numeric constraints are enforced application-side. | +| `password` | `input` | Text input with masked TUI display. Should set `sensitive: true`; secret values should return via normal stdin keystrokes, not OSC `resolve`. | +| `number` | `input` | Numeric input with validation, min/max bounds, and up/down stepping. Numeric resolve values may be string or number. | | `search` | `select` | Filterable select with a text query. The OSC payload is identical to `select` (all options included). Filtering is a TUI-side behavior. | ### Group @@ -194,9 +195,13 @@ Terminal hosts can render these as toast notifications, status bar updates, or s 1. Register an OSC handler for code 7770 2. Parse the JSON payload 3. If `type` is not `"resolve"`, display a native UI (modal, panel, buttons) -4. When the user makes a selection, write the resolve sequence into PTY stdin +4. When the user makes a selection, write the resolve sequence into PTY stdin (except `sensitive: true`, where host should inject keystrokes) 5. The application's stdin handler detects the resolve and completes the prompt +For prompts with `sensitive: true`, hosts should not send secret values in +OSC resolve payloads. If intercepting, hosts should inject normal stdin +keystrokes instead. + The resolve sequence is written as raw bytes into the PTY's stdin file descriptor. The application's prompt library parses it. ### xterm.js Example diff --git a/README.md b/README.md index b3d0c7f..e650fb4 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,8 @@ Keys: `Space` to toggle, `a` to toggle all, `Enter` to submit. ### password Masked text input. +Password prompts are marked as sensitive: secret values are not sent in OSC +`resolve` payloads. ```typescript const secret = await password({ @@ -421,6 +423,9 @@ ESC ] 7770 ; {"v":1,"type":"select","id":"...","message":"Pick a framework","opt Terminal hosts (web terminals, IDE terminals, multiplexers) can register an OSC handler for code `7770`, intercept the payload, and render native UI (dropdowns, modals, checkboxes, progress bars) instead of the TUI. When the user makes a selection, the host writes a resolve message back to PTY stdin. +For sensitive prompts (for example `password`), hosts should return values as +normal PTY keystrokes instead of OSC `resolve` payload values. + Terminals that don't support OSC 7770 silently ignore the sequences per ECMA-48. The TUI works exactly as it would without the protocol. Your code doesn't change. No feature flags. No configuration. The library handles everything. diff --git a/SPEC.md b/SPEC.md index ed223d6..5191a65 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,9 +1,9 @@ # OSC 7770: Structured Terminal Prompts -**Version:** 1.1.0 -**Date:** 2026-03-07 +**Version:** 1.1.1 +**Date:** 2026-03-09 **Status:** Living Standard -**Authors:** Termprompt Contributors +**Authors:** Zlatko Fedor **Canonical URL:** https://github.com/seeden/termprompt/blob/main/SPEC.md **License:** MIT @@ -249,11 +249,12 @@ type. See [Section 8](#8-prompt-types) for per-type field definitions. | Field | Type | Applicable Types | Description | |----------------|------------|--------------------|-------------| | `options` | `array` | select, multiselect | Array of option objects. See below. | -| `placeholder` | `string` | input | Placeholder text shown when value is empty. | +| `placeholder` | `string` | input, select (search variant) | Placeholder text shown when value is empty. | | `initialValue` | `any` | select, confirm, input | Default value. | | `initialValues`| `array` | multiselect | Default selected values. | | `active` | `string` | confirm | Label for the affirmative choice. Default: `"Yes"`. | | `inactive` | `string` | confirm | Label for the negative choice. Default: `"No"`. | +| `sensitive` | `boolean` | input | If `true`, resolved value MUST NOT be transmitted in OSC `resolve` payloads. | #### Option Object @@ -302,7 +303,8 @@ in two distinct scenarios: 2. **Application -> Terminal host (via stdout).** When the user interacts with the TUI fallback normally (no host interception), the application - emits a resolve message to stdout after the prompt completes. This + emits a resolve message to stdout after the prompt completes (except for + prompts marked `sensitive: true`). This allows the terminal host to track prompt state even when it chose not to intercept. @@ -626,23 +628,30 @@ The `options` array MUST contain at least one non-disabled option. The An application-level variant of `input` that masks user input in the TUI. The OSC wire type is `"input"`. Terminal hosts that intercept an `input` prompt cannot distinguish a password prompt from a regular text input based -on the OSC payload alone. The masking is purely a TUI rendering concern. +on the OSC payload alone unless the optional `sensitive` field is present. +The masking is primarily a TUI rendering concern. **Wire type:** `"input"` **Required fields:** `message` -**Optional fields:** `placeholder` +**Optional fields:** `placeholder`, `sensitive` (SHOULD be `true`) The application renders each typed character as a mask symbol (e.g., `*`) in the TUI. The `message` and `placeholder` fields are visible in the OSC -payload, but the user's typed response is never transmitted via OSC 7770 -(it travels through normal stdin). +payload. **Resolve value type:** `string` #### Security Note -See Section 12.2. The prompt message is transmitted in cleartext. The -actual password value is never included in an OSC sequence. +For password prompts, applications SHOULD set `sensitive: true`. For +`sensitive` prompts: + +- The application MUST NOT emit a stdout OSC `resolve` containing the user's value. +- The terminal host MUST NOT send a stdin OSC `resolve` containing the user's value. +- If the terminal host intercepts, it SHOULD inject normal stdin keystrokes + (as if the user typed) rather than a resolve payload. + +See Section 12.2. ### 8.6. number @@ -658,8 +667,8 @@ The TUI restricts keystroke input to numeric characters (`0-9`, `-`, `.`) and supports up/down arrow keys for incrementing and decrementing by a configurable step value. -**Resolve value type:** `string` (the terminal host sends a string; the -application parses it as a number) +**Resolve value type:** `number` or numeric `string` (the terminal host MAY +send either; the application parses and validates as number) #### Terminal Host Rendering Suggestions @@ -732,7 +741,9 @@ When a terminal host receives a prompt announcement (type is `"select"`, 1. **Intercept:** Suppress the TUI rendering and display a native UI component. When the user makes a selection, write a resolve message - (Section 7.2) into the PTY's stdin file descriptor as raw bytes. + (Section 7.2) into the PTY's stdin file descriptor as raw bytes. For + prompts with `sensitive: true`, do not send value-carrying resolve + payloads; inject normal stdin keystrokes instead. 2. **Observe:** Record the prompt metadata without intercepting. Allow the TUI fallback to proceed normally. Optionally listen for the @@ -740,14 +751,20 @@ When a terminal host receives a prompt announcement (type is `"select"`, 3. **Ignore:** Discard the message entirely. -A terminal host that intercepts a prompt MUST write the resolve message -before the application's TUI times out or the user interacts with the TUI -fallback. Race conditions between host-initiated and TUI-initiated input -are resolved by the application accepting whichever completes first. +A terminal host that intercepts a prompt MUST complete the interaction +(resolve message for non-sensitive prompts, or stdin keystroke injection +for sensitive prompts) before the application's TUI times out or the user +interacts with the TUI fallback. Race conditions between host-initiated and +TUI-initiated input are resolved by the application accepting whichever +completes first. + +For prompts with `sensitive: true`, terminal hosts MUST NOT send the secret +value in an OSC `resolve` payload. If intercepting, hosts SHOULD inject +normal stdin keystrokes instead. ### 9.3. Non-Interactive Events -For spinner and log messages, the terminal host MAY render enhanced UI +For spinner, progress, tasks, and log messages, the terminal host MAY render enhanced UI (progress bars, toast notifications, structured log panels). The terminal host MUST NOT write any data to PTY stdin in response to these messages. @@ -802,6 +819,9 @@ prefix (`ESC ] 7770 ;`) and extract the JSON payload. If the parsed message is a valid resolve for the active prompt, the application MUST complete the prompt with the provided value and clean up TUI state. +For prompts marked `sensitive: true`, applications MUST ignore OSC resolve +payloads that carry user values and rely on normal stdin keystrokes. + Non-matching data on stdin MUST be processed as normal keystroke input. --- @@ -843,10 +863,8 @@ resolved values against expected types and ranges. Prompt messages and option values are transmitted as cleartext in the OSC sequence. Applications SHOULD NOT include secrets, credentials, or personally identifiable information in prompt payloads. For password-type -prompts, the `input` type may be used with the understanding that the -`placeholder` and `message` fields are visible in the escape sequence, but -the user's typed response is not transmitted via OSC 7770 (it travels -through normal stdin). +prompts, applications SHOULD set `sensitive: true` and MUST NOT transport +the secret value in OSC resolve payloads in either direction. ### 12.3. JSON Parsing @@ -964,7 +982,7 @@ New message types or optional fields do not require a version increment. ### Plain Text -> OSC 7770: Structured Terminal Prompts, Version 1.0.0, 2026. +> OSC 7770: Structured Terminal Prompts, Version 1.1.1, 2026. > https://github.com/seeden/termprompt/blob/main/SPEC.md ### BibTeX @@ -972,11 +990,11 @@ New message types or optional fields do not require a version increment. ```bibtex @techreport{osc7770, title = {{OSC} 7770: Structured Terminal Prompts}, - author = {{Termprompt Contributors}}, + author = {Zlatko Fedor}, year = {2026}, month = mar, type = {Living Standard}, - version = {1.0.0}, + version = {1.1.1}, url = {https://github.com/seeden/termprompt/blob/main/SPEC.md}, note = {Specification for structured interactive prompt announcements over terminal escape sequences} @@ -985,20 +1003,28 @@ New message types or optional fields do not require a version increment. ### APA -Termprompt Contributors. (2026). *OSC 7770: Structured terminal prompts* -(Version 1.0.0) [Living Standard]. +Fedor, Z. (2026). *OSC 7770: Structured terminal prompts* +(Version 1.1.1) [Living Standard]. https://github.com/seeden/termprompt/blob/main/SPEC.md ### Chicago -Termprompt Contributors. "OSC 7770: Structured Terminal Prompts." -Version 1.0.0. Living Standard, March 2026. +Fedor, Zlatko. "OSC 7770: Structured Terminal Prompts." +Version 1.1.1. Living Standard, March 2026. https://github.com/seeden/termprompt/blob/main/SPEC.md. --- ## 17. Changelog +### Version 1.1.1 (2026-03-09) + +- Added optional `sensitive` prompt field for input-style prompts. +- Defined secure handling for `sensitive` prompts: no OSC resolve values. +- Clarified `number` resolve value typing (numeric string or number). +- Clarified placeholder applicability for search (`select` wire type). +- Clarified non-interactive handling for `progress` and `tasks`. + ### Version 1.1.0 (2026-03-07) - Added `progress` message type for determinate progress bars. diff --git a/package.json b/package.json index dfb39e3..7289ecb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "private": true, - "version": "0.2.1", + "version": "0.2.2", + "type": "module", "packageManager": "pnpm@10.29.3", "scripts": { "build": "pnpm -r build", diff --git a/packages/protocol/README.md b/packages/protocol/README.md index 322c2cb..8611193 100644 --- a/packages/protocol/README.md +++ b/packages/protocol/README.md @@ -32,7 +32,8 @@ for (const msg of messages) { case 'confirm': case 'input': case 'multiselect': - // Interactive prompt - show native UI, then resolve + // Interactive prompt - show native UI, then resolve. + // If payload.sensitive === true, return via stdin keystrokes instead. break; case 'spinner': case 'progress': @@ -57,6 +58,9 @@ const data = encodeResolve(promptId, selectedValue); pty.write(data); ``` +For prompts with `sensitive: true`, do not send secret values in OSC +`resolve` payloads. Inject normal stdin keystrokes instead. + ### Encode a prompt announcement ```typescript diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 3de406d..57f6de7 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@termprompt/protocol", - "version": "0.2.1", + "version": "0.2.2", "type": "module", "description": "OSC 7770 protocol parser and encoder for termprompt", "license": "MIT", diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 02e070d..98867e3 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -3,6 +3,7 @@ export type OscPromptPayload = { type: "select" | "confirm" | "input" | "multiselect"; id: string; message: string; + sensitive?: boolean; options?: Array<{ value: unknown; label: string; diff --git a/packages/termprompt/README.md b/packages/termprompt/README.md deleted file mode 100644 index db24515..0000000 --- a/packages/termprompt/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# termprompt - -Beautiful terminal prompts for Node.js. Zero dependencies. - -Every prompt emits [OSC 7770](https://github.com/seeden/termprompt/blob/main/SPEC.md) escape sequences alongside the TUI. Smart terminals can intercept the structured data and render native UI. Standard terminals show the TUI. Zero config, zero degradation. - -**[Documentation](https://seeden.github.io/termprompt/)** | **[GitHub](https://github.com/seeden/termprompt)** - -## Install - -```bash -npm install termprompt -``` - -## Quick start - -```typescript -import { setTheme, intro, outro, select, input, isCancel, log } from 'termprompt'; - -setTheme({ accent: '#7c3aed' }); -intro('create-app'); - -const name = await input({ - message: 'Project name?', - placeholder: 'my-app', -}); -if (isCancel(name)) process.exit(0); - -const framework = await select({ - message: 'Pick a framework', - options: [ - { value: 'next', label: 'Next.js', hint: 'React SSR' }, - { value: 'hono', label: 'Hono', hint: 'Edge-first' }, - { value: 'astro', label: 'Astro', hint: 'Content sites' }, - ], -}); -if (isCancel(framework)) process.exit(0); - -log.success(`Created ${name} with ${framework}.`); -outro('Happy coding.'); -``` - -## Components - -**Prompts:** `select`, `confirm`, `input`, `multiselect`, `password`, `number`, `search`, `group` - -**Display:** `spinner`, `progress`, `tasks`, `note`, `log`, `intro`, `outro` - -**Theming:** `setTheme({ accent, success, error, warning, info })` - -See the [full documentation](https://seeden.github.io/termprompt/) for API details and examples. - -## License - -MIT diff --git a/packages/termprompt/README.md b/packages/termprompt/README.md new file mode 120000 index 0000000..fe84005 --- /dev/null +++ b/packages/termprompt/README.md @@ -0,0 +1 @@ +../../README.md \ No newline at end of file diff --git a/packages/termprompt/package.json b/packages/termprompt/package.json index aca4287..e03cc92 100644 --- a/packages/termprompt/package.json +++ b/packages/termprompt/package.json @@ -1,6 +1,6 @@ { "name": "termprompt", - "version": "0.2.1", + "version": "0.2.2", "type": "module", "description": "Beautiful terminal prompts with OSC 7770 structured output for rich terminal hosts", "license": "MIT", diff --git a/packages/termprompt/src/__tests__/prompt.test.ts b/packages/termprompt/src/__tests__/prompt.test.ts index a3bf48d..6a6862a 100644 --- a/packages/termprompt/src/__tests__/prompt.test.ts +++ b/packages/termprompt/src/__tests__/prompt.test.ts @@ -216,6 +216,38 @@ describe("createPrompt", () => { expect(out).toContain('"value":"local-picked"'); }); + it("does not emit OSC resolve when prompt is sensitive", async () => { + const { input, output, getOutput, pressKey } = createTestStreams(); + + const promise = createPrompt({ + render: () => "prompt", + onKey: (key, current) => { + if (key.name === "return") { + return { value: current.value, state: "submit" }; + } + return undefined; + }, + initialValue: "super-secret", + osc: { + v: 1, + type: "input", + id: "sensitive-submit", + message: "Password?", + sensitive: true, + }, + input, + output, + }); + + pressKey("return"); + const result = await promise; + + expect(result).toBe("super-secret"); + const out = getOutput(); + expect(out).not.toContain('"type":"resolve"'); + expect(out).not.toContain("super-secret"); + }); + it("emits OSC resolve with structured values on TUI submit", async () => { const { input, output, getOutput, pressKey } = createTestStreams(); const resolvedValue = { id: 7, tags: ["a", "b"] }; @@ -273,6 +305,38 @@ describe("createPrompt", () => { expect(getOutput()).not.toContain('"type":"resolve"'); }); + it("ignores host OSC resolve for sensitive prompts", async () => { + const { input, output, getOutput, pressKey } = createTestStreams(); + + const promise = createPrompt({ + render: () => "prompt", + onKey: (key, current) => { + if (key.name === "return") { + return { value: current.value, state: "submit" }; + } + return undefined; + }, + initialValue: "typed-secret", + osc: { + v: 1, + type: "input", + id: "sensitive-host", + message: "Password?", + sensitive: true, + }, + input, + output, + }); + + input.write(Buffer.from(encodeResolve("sensitive-host", "host-secret"))); + pressKey("return"); + const result = await promise; + + expect(result).toBe("typed-secret"); + expect(getOutput()).not.toContain('"value":"host-secret"'); + expect(getOutput()).not.toContain('"type":"resolve"'); + }); + it("does not emit OSC resolve when prompt is cancelled", async () => { const { input, output, getOutput, pressKey } = createTestStreams(); diff --git a/packages/termprompt/src/core/prompt.ts b/packages/termprompt/src/core/prompt.ts index 9383906..fe0385c 100644 --- a/packages/termprompt/src/core/prompt.ts +++ b/packages/termprompt/src/core/prompt.ts @@ -77,7 +77,8 @@ export function createPrompt(options: PromptOptions): Promise renderFrame(); output.write("\n"); - if (source === "submit-tui" && osc) { + // Sensitive prompts must never echo resolved values via OSC on stdout. + if (source === "submit-tui" && osc && !osc.sensitive) { output.write(encodeResolve(osc.id, result)); } @@ -100,6 +101,7 @@ export function createPrompt(options: PromptOptions): Promise for (const message of parsed.messages) { const payload = message.payload; if (payload.type !== "resolve" || payload.id !== osc.id) continue; + if (osc.sensitive) continue; try { const resolvedValue = parseOscResolveValue diff --git a/packages/termprompt/src/prompts/password.ts b/packages/termprompt/src/prompts/password.ts index 0729c14..8a07bd1 100644 --- a/packages/termprompt/src/prompts/password.ts +++ b/packages/termprompt/src/prompts/password.ts @@ -46,6 +46,7 @@ export async function password( type: "input", id: promptId, message, + sensitive: true, placeholder: undefined, }, parseOscResolveValue(value: unknown) {