From a7767ba76c5b1baca77cd6d3e02ae3f044d45392 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 6 Nov 2026 08:44:17 -0600 Subject: [PATCH 1/3] fix(core): pass DPAPI secrets to PowerShell over stdin, not the command line The Windows keyring (DpapiFileStore) interpolated the secret into the PowerShell -Command string with JSON.stringify. A token was therefore visible in the process list and in process-creation auditing for the lifetime of the call, and PowerShell's own parsing could still mangle quotes, backticks and $() inside the quoted value. The secret now goes over stdin: the fixed command sets UTF-8 on both console streams, reads stdin to the end, runs the protect or unprotect step on that value and writes the result as compact JSON, which the caller parses. Unprotect zeroes and frees the BSTR and disposes the SecureString after reading. Any subprocess failure, non-zero exit or non-string result is reported as "Windows credential protection failed; check PowerShell and your Windows profile", never with the arguments. Tests (win32 only): a round trip of a value with fake JSON, shell syntax, Unicode and surrounding whitespace that must not appear in the secrets file in clear, and a corrupt blob that must fail with the redacted message. Run with QQQ_POWERSHELL=pwsh where Windows PowerShell 5.1 cannot load Microsoft.PowerShell.Security. Carried locally by GG Forge since cli-v1.0.0. Co-Authored-By: Claude Fable 5.1 Signed-off-by: James Maes --- CHANGELOG.md | 1 + packages/core/src/session/keyring.ts | 21 +++++++++++++--- packages/core/src/test/dpapi.test.ts | 37 ++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/test/dpapi.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fa7196b..6376db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to the qqq CLI and its packages. Versions follow [semver](ht ### Fixed +- **Windows: the DPAPI keyring passes secrets to PowerShell over stdin**, not on the command line, so a token never appears in the process list or in the Windows event log, and JSON, shell syntax, Unicode and leading or trailing whitespace round-trip unchanged. Secrets are decoded with UTF-8 on both sides, the decrypted BSTR is zeroed after reading, and a PowerShell failure reports "Windows credential protection failed" instead of the subprocess arguments. Carried locally by GG Forge since cli-v1.0.0. - The screen saver's Online tile said "nobody around" with two terminals signed in. It now counts signed-in sessions first (the session store's table: sessions active in the last 10 minutes and their users, your own included, "2 sessions · 1 user (you ×2)"), else you plus the people in collaboration presence; it never says nobody is around while you are signed in. The saver also keeps its presence lease on the screen under it (the extension has no idle state), so other clients see you still viewing it instead of gone. A server that stores no sessions and merges presence per user (mock auth) shows both terminals as "1 online · you". - Query in the screen saver looked like a different dog. All sizes are now generated from the drawing on https://www.qrun.io/query/ (`scripts/gen-query-art.mjs`) in its indigo, with a new medium size (25×13 cells, used where there is room) between the large (42×22) and the small (17×9). ### Added diff --git a/packages/core/src/session/keyring.ts b/packages/core/src/session/keyring.ts index 5888014..25e4f80 100644 --- a/packages/core/src/session/keyring.ts +++ b/packages/core/src/session/keyring.ts @@ -104,13 +104,28 @@ export class DpapiFileStore implements SecretStore { } private async ps(script: string, input: string): Promise { const shell = process.env.QQQ_POWERSHELL ?? 'powershell' - const { stdout } = await run(shell, ['-NoProfile', '-NonInteractive', '-Command', script.replace('$INPUT', JSON.stringify(input))]) - return stdout.trim() + const command = "$ErrorActionPreference = 'Stop'; [Console]::InputEncoding = [Text.UTF8Encoding]::new($false); " + + '[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false); $qqqSecretInput = [Console]::In.ReadToEnd(); ' + + '$qqqResult = & { ' + script.replace('$INPUT', '$qqqSecretInput') + ' }; ' + + '[Console]::Write((ConvertTo-Json -InputObject $qqqResult -Compress))' + return new Promise((resolve, reject) => { + const failed = () => reject(new Error('Windows credential protection failed; check PowerShell and your Windows profile')) + const child = execFile(shell, ['-NoProfile', '-NonInteractive', '-Command', command], { timeout: 15000 }, (error, stdout) => { + if (error) { failed(); return } + try { + const result: unknown = JSON.parse(stdout.trim()) + if (typeof result !== 'string') { failed(); return } + resolve(result) + } catch { failed() } + }) + child.stdin?.on('error', failed) + child.stdin?.end(input, 'utf8') + }) } async get(account: string): Promise { const blob = this.read()[account] if (!blob) return undefined - return this.ps('$s = ConvertTo-SecureString -String $INPUT; [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))', blob) + return this.ps('$s = ConvertTo-SecureString -String $INPUT; $p = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($s); try { [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($p) } finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($p); $s.Dispose() }', blob) } async set(account: string, secret: string): Promise { const blob = await this.ps('ConvertTo-SecureString -String $INPUT -AsPlainText -Force | ConvertFrom-SecureString', secret) diff --git a/packages/core/src/test/dpapi.test.ts b/packages/core/src/test/dpapi.test.ts new file mode 100644 index 0000000..4ada53f --- /dev/null +++ b/packages/core/src/test/dpapi.test.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2026 QRun.IO, Inc. + * Licensed under the Apache License, Version 2.0 (see LICENSE). + */ + +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { DpapiFileStore } from '../session/keyring.js' + +test('Windows DPAPI preserves JSON, shell syntax, Unicode and whitespace without plaintext storage', { skip: process.platform !== 'win32' }, async () => { + const dir = mkdtempSync(join(tmpdir(), 'qqq-dpapi-fixture-')) + const file = join(dir, 'secrets.dpapi.json') + const secret = ' ' + JSON.stringify({ token: 'obviously-fake-credential', syntax: '\"\'`$(){}\\', text: 'héllo 世界\nsecond line' }) + '\n ' + try { + const store = new DpapiFileStore(file) + await store.set('fixture', secret) + assert.equal(readFileSync(file, 'utf8').includes('obviously-fake-credential'), false) + assert.equal(await new DpapiFileStore(file).get('fixture'), secret) + assert.equal(await store.delete('fixture'), true) + assert.equal(await store.get('fixture'), undefined) + } finally { rmSync(dir, { recursive: true, force: true }) } +}) + +test('Windows credential failure does not expose subprocess arguments or credential material', { skip: process.platform !== 'win32' }, async () => { + const dir = mkdtempSync(join(tmpdir(), 'qqq-dpapi-failure-')) + const file = join(dir, 'secrets.dpapi.json') + try { + writeFileSync(file, JSON.stringify({ fixture: 'obviously-fake-invalid-dpapi-value' })) + await assert.rejects(new DpapiFileStore(file).get('fixture'), error => { + assert.equal(String(error), 'Error: Windows credential protection failed; check PowerShell and your Windows profile') + return true + }) + } finally { rmSync(dir, { recursive: true, force: true }) } +}) -- 2.54.0 From b126064144151ef65a93bff6a1890887fcceabb9 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 6 Nov 2026 08:44:33 -0600 Subject: [PATCH 2/3] fix(cli): open the browser on Windows without cmd /c start splitting the URL openInBrowser used `cmd /c start "" ` on Windows. cmd treats `&` as a command separator even inside an argument that was passed as one string, so an OAuth authorization URL was cut at its first query parameter: the browser opened the authorization endpoint without client_id, state or redirect_uri and the loopback callback never arrived. Only `qqq login --browser` was affected; the printed URL worked when pasted. The URL is now never shell text. The opener runs PowerShell (QQQ_POWERSHELL, else powershell.exe) with a fixed -EncodedCommand, `Start-Process -FilePath $env:QQQ_BROWSER_URL`, and the URL in that environment variable; the child is detached, stdio ignored and windowsHide set so no console window flashes. Test (win32 only): stubs child_process.spawn to capture the call, checks the command is not cmd, the URL is in the environment and windowsHide is set, then runs the captured encoded command in a real PowerShell with Start-Process shadowed to print its -FilePath, and expects the full URL back, `&` and all. Carried locally by GG Forge since cli-v1.0.0. Co-Authored-By: Claude Fable 5.1 Signed-off-by: James Maes --- CHANGELOG.md | 1 + packages/cli/src/browser-login.ts | 4 +-- packages/cli/src/test/browser-opener.test.ts | 33 ++++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/test/browser-opener.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6376db3..f34bbef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to the qqq CLI and its packages. Versions follow [semver](ht ### Fixed - **Windows: the DPAPI keyring passes secrets to PowerShell over stdin**, not on the command line, so a token never appears in the process list or in the Windows event log, and JSON, shell syntax, Unicode and leading or trailing whitespace round-trip unchanged. Secrets are decoded with UTF-8 on both sides, the decrypted BSTR is zeroed after reading, and a PowerShell failure reports "Windows credential protection failed" instead of the subprocess arguments. Carried locally by GG Forge since cli-v1.0.0. +- **Windows: `qqq login --browser` opens the whole authorization URL.** The opener was `cmd /c start`, which splits the URL at every `&`, so the browser received only the part before the first query parameter (`client_id`, `state` and `redirect_uri` were lost) and the loopback redirect never came. The URL now goes to PowerShell in the environment variable `QQQ_BROWSER_URL` (`Start-Process -FilePath $env:QQQ_BROWSER_URL`, a fixed encoded command, hidden window; `QQQ_POWERSHELL` picks the shell). Carried locally by GG Forge since cli-v1.0.0. - The screen saver's Online tile said "nobody around" with two terminals signed in. It now counts signed-in sessions first (the session store's table: sessions active in the last 10 minutes and their users, your own included, "2 sessions · 1 user (you ×2)"), else you plus the people in collaboration presence; it never says nobody is around while you are signed in. The saver also keeps its presence lease on the screen under it (the extension has no idle state), so other clients see you still viewing it instead of gone. A server that stores no sessions and merges presence per user (mock auth) shows both terminals as "1 online · you". - Query in the screen saver looked like a different dog. All sizes are now generated from the drawing on https://www.qrun.io/query/ (`scripts/gen-query-art.mjs`) in its indigo, with a new medium size (25×13 cells, used where there is room) between the large (42×22) and the small (17×9). ### Added diff --git a/packages/cli/src/browser-login.ts b/packages/cli/src/browser-login.ts index 21a1714..8b8bdd6 100644 --- a/packages/cli/src/browser-login.ts +++ b/packages/cli/src/browser-login.ts @@ -30,9 +30,9 @@ export interface BrowserSignInOptions { /** Opens a URL with the platform's opener; failures are ignored (the URL is printed anyway). */ export function openInBrowser(url: string): void { - const [command, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]] + const [command, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? [process.env.QQQ_POWERSHELL || 'powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from('Start-Process -FilePath $env:QQQ_BROWSER_URL', 'utf16le').toString('base64')]] : ['xdg-open', [url]] try { - const child = spawn(command, args as string[], { stdio: 'ignore', detached: true }) + const child = spawn(command, args as string[], { stdio: 'ignore', detached: true, windowsHide: true, env: { ...process.env, QQQ_BROWSER_URL: url } }) child.on('error', () => undefined) child.unref() } catch { diff --git a/packages/cli/src/test/browser-opener.test.ts b/packages/cli/src/test/browser-opener.test.ts new file mode 100644 index 0000000..459efe7 --- /dev/null +++ b/packages/cli/src/test/browser-opener.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2026 QRun.IO, Inc. + * Licensed under the Apache License, Version 2.0 (see LICENSE). + */ + + +import assert from 'node:assert/strict' +import { test } from 'node:test' +import childProcess from 'node:child_process' +import { syncBuiltinESMExports } from 'node:module' +import { EventEmitter } from 'node:events' +import { openInBrowser } from '../browser-login.js' + +test('Windows browser opener preserves OAuth query parameters without shell interpretation', { skip: process.platform !== 'win32' }, () => { + const original = childProcess.spawn + const url = 'https://example.invalid/authorize?response_type=code&client_id=fixture-native&state=fake%20state&redirect_uri=http%3A%2F%2F127.0.0.1%3A4567%2Fcallback' + let captured: unknown[] = [] + childProcess.spawn = ((...args: unknown[]) => { + captured = args + return Object.assign(new EventEmitter(), { unref() {} }) + }) as typeof childProcess.spawn + syncBuiltinESMExports() + try { openInBrowser(url) } finally { childProcess.spawn = original; syncBuiltinESMExports() } + const [command, args, options] = captured as [string, string[], { env: NodeJS.ProcessEnv; windowsHide: boolean }] + assert.notEqual(command, 'cmd') + assert.equal(options.env.QQQ_BROWSER_URL, url) + assert.equal(options.windowsHide, true) + const script = Buffer.from(args.at(-1)!, 'base64').toString('utf16le') + const probe = 'function Start-Process { param([string]$FilePath) [Console]::Write($FilePath) }; ' + script + const result = childProcess.spawnSync(command, ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(probe, 'utf16le').toString('base64')], { env: options.env, encoding: 'utf8', windowsHide: true }) + assert.equal(result.status, 0, result.stderr) + assert.equal(result.stdout, url) +}) -- 2.54.0 From 2c3114e06b24068d5927db59f08f7eb21d977f72 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 6 Nov 2026 08:45:03 -0600 Subject: [PATCH 3/3] fix(tui): check the record's tag feature and capability before the tag editor The record screen opened the TagOverlay for any record whenever the collaboration extension was present. On a table without the TAGS feature, or for a role without the `tag` capability, the editor opened and the save failed only when the server refused it. openTags now calls getRecordFeatures for the record and opens the overlay only when `features` contains TAGS and `capabilities.tag` is true; otherwise it flashes "Tags are not enabled for this table." or "Your role cannot tag this record.". The answer is ignored when the screen has moved to another record meanwhile, and a request error is flashed like any other. Tests: the fake server answers /table/person//features from a `recordTagPolicy` (enabled, allowed; reset with the rest of the collaboration state), and enterprise.test.tsx presses t with the feature off and with the capability denied and expects the message and no overlay. The existing tag editor test keeps its defaults. Carried locally by GG Forge since cli-v1.0.0. Co-Authored-By: Claude Fable 5.1 Signed-off-by: James Maes --- CHANGELOG.md | 1 + packages/tui/src/screens/record.tsx | 10 +++++++++- packages/tui/test/enterprise.test.tsx | 17 ++++++++++++++++- packages/tui/test/fake-collab.ts | 4 ++++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f34bbef..83a8945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to the qqq CLI and its packages. Versions follow [semver](ht - **Windows: the DPAPI keyring passes secrets to PowerShell over stdin**, not on the command line, so a token never appears in the process list or in the Windows event log, and JSON, shell syntax, Unicode and leading or trailing whitespace round-trip unchanged. Secrets are decoded with UTF-8 on both sides, the decrypted BSTR is zeroed after reading, and a PowerShell failure reports "Windows credential protection failed" instead of the subprocess arguments. Carried locally by GG Forge since cli-v1.0.0. - **Windows: `qqq login --browser` opens the whole authorization URL.** The opener was `cmd /c start`, which splits the URL at every `&`, so the browser received only the part before the first query parameter (`client_id`, `state` and `redirect_uri` were lost) and the loopback redirect never came. The URL now goes to PowerShell in the environment variable `QQQ_BROWSER_URL` (`Start-Process -FilePath $env:QQQ_BROWSER_URL`, a fixed encoded command, hidden window; `QQQ_POWERSHELL` picks the shell). Carried locally by GG Forge since cli-v1.0.0. +- **`qqq ui`: Tags (t) asks the server first.** The record screen reads the record's collaboration features (`getRecordFeatures`) before opening the tag editor and says "Tags are not enabled for this table." or "Your role cannot tag this record." instead of opening an editor whose save the server would refuse. Carried locally by GG Forge since cli-v1.0.0. - The screen saver's Online tile said "nobody around" with two terminals signed in. It now counts signed-in sessions first (the session store's table: sessions active in the last 10 minutes and their users, your own included, "2 sessions · 1 user (you ×2)"), else you plus the people in collaboration presence; it never says nobody is around while you are signed in. The saver also keeps its presence lease on the screen under it (the extension has no idle state), so other clients see you still viewing it instead of gone. A server that stores no sessions and merges presence per user (mock auth) shows both terminals as "1 online · you". - Query in the screen saver looked like a different dog. All sizes are now generated from the drawing on https://www.qrun.io/query/ (`scripts/gen-query-art.mjs`) in its indigo, with a new medium size (25×13 cells, used where there is room) between the large (42×22) and the small (17×9). ### Added diff --git a/packages/tui/src/screens/record.tsx b/packages/tui/src/screens/record.tsx index dc84fe1..21d5327 100644 --- a/packages/tui/src/screens/record.tsx +++ b/packages/tui/src/screens/record.tsx @@ -611,7 +611,15 @@ export class RecordScreen implements Screen { const collab = collaborationOf(this.store) if (!collab) return this.store.flash(this.store.extensions.missing('collaboration'), 'error') if (this.primaryKey === undefined) return - this.store.open(new TagOverlay(this.store, collab, this.tableName, this.primaryKey, (t) => { this.tags = t; this.build() })) + const tableName = this.tableName + const primaryKey = this.primaryKey + this.store.client!.call('getRecordFeatures', { path: { tableName, primaryKey: String(primaryKey) } }).then((result) => { + if (this.tableName !== tableName || this.primaryKey !== primaryKey) return + const features = result.data as { features?: string[]; capabilities?: Record } + if (!features?.features?.includes('TAGS')) return this.store.flash('Tags are not enabled for this table.', 'error') + if (features.capabilities?.tag !== true) return this.store.flash('Your role cannot tag this record.', 'error') + this.store.open(new TagOverlay(this.store, collab, tableName, primaryKey, (t) => { this.tags = t; this.build() })) + }).catch((error) => this.store.flash(errorText(error), 'error')) } /** Counts, follow state and the panel details of the strip and the panel (collaboration extension). */ diff --git a/packages/tui/test/enterprise.test.tsx b/packages/tui/test/enterprise.test.tsx index 7ec141e..17abcd3 100644 --- a/packages/tui/test/enterprise.test.tsx +++ b/packages/tui/test/enterprise.test.tsx @@ -13,7 +13,7 @@ import { TuiClient } from '../src/client' import { RecordCollaboration } from '../src/collab' import type { RecordScreen } from '../src/screens/record' import { readViewSettings } from '@qrun-io/qqq-core' -import { COLLABORATION, QUICK_SEARCH, collabState, fakeExtensions, resetCollab } from './fake-collab' +import { COLLABORATION, QUICK_SEARCH, collabState, fakeExtensions, resetCollab, recordTagPolicy } from './fake-collab' import type { RecordViewSettingsOverlay } from '../src/overlays/record-view-settings' import { fakeFetch } from './fake-server' import { command, isolate, press, startTui, waitFor, type Harness } from './helpers' @@ -240,3 +240,18 @@ test('record view without the collaboration extension: no strip, no panel, the c expect(f).not.toContain('Collaboration') expect((h.store.screen as RecordScreen).list.current?.key).toStartWith('f:') }) + +for (const enabled of [false, true]) { + test(`tag editor respects server capability: enabled=${enabled}, permission denied`, async () => { + fakeExtensions.push(COLLABORATION) + recordTagPolicy.enabled = enabled + recordTagPolicy.allowed = false + h = await startTui(160, 40) + await command(h, 'table get person 1') + await waitFor(h, (x) => x.includes('Tags (t)')) + await key('t') + const text = enabled ? 'Your role cannot tag this record.' : 'Tags are not enabled for this table.' + await waitFor(h, (x) => x.includes(text)) + expect(h.store.overlays.length).toBe(0) + }) +} \ No newline at end of file diff --git a/packages/tui/test/fake-collab.ts b/packages/tui/test/fake-collab.ts index 6c2a855..eefade8 100644 --- a/packages/tui/test/fake-collab.ts +++ b/packages/tui/test/fake-collab.ts @@ -15,6 +15,7 @@ const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), export const fakeExtensions: J[] = [] export const COLLABORATION = { name: 'collaboration', version: '1.1.0', pathPrefix: '/qbit/collaboration', description: 'Record collaboration' } +export const recordTagPolicy = { enabled: true, allowed: true } export const QUICK_SEARCH = { name: 'quick-search', version: '1.1.0', pathPrefix: '/qbit/quick-search', description: 'Quick search' } export interface CollabState { @@ -37,11 +38,14 @@ export function resetCollab(): void { collabState.uploads = [] collabState.shares = [] collabState.tags = [] + recordTagPolicy.enabled = true + recordTagPolicy.allowed = true } resetCollab() export async function collabRoute(path: string, method: string, body: unknown): Promise { const on = (name: string) => fakeExtensions.some((e) => e.name === name) + if (/^\/table\/person\/\d+\/features$/.test(path)) return json({ features: recordTagPolicy.enabled ? ['TAGS'] : [], capabilities: { tag: recordTagPolicy.allowed } }) if (path === '/search' && method === 'POST' && on('quick-search')) { const term = String((body as J | undefined)?.searchTerm ?? '').toLowerCase() const hits = 'ada lovelace'.includes(term) ? [{ tableName: 'person', tableLabel: 'Person', recordId: '1', recordLabel: '1', highlights: [{ text: 'Ada Lovelace', fieldLabel: 'Name' }] }] : [] -- 2.54.0