diff --git a/packages/agent-modules/permission/src/tools/fs-checker.ts b/packages/agent-modules/permission/src/tools/fs-checker.ts index 61ef671f4..47259584f 100644 --- a/packages/agent-modules/permission/src/tools/fs-checker.ts +++ b/packages/agent-modules/permission/src/tools/fs-checker.ts @@ -53,7 +53,11 @@ export class FsToolPermissionChecker implements ToolPermissionChecker { } // Extract file path from input - const filePath = extractFilePath(input); + const filePath = + extractFilePath(input) ?? + (toolName === 'grep' || toolName === 'glob' || toolName === 'list' + ? context.workingDirectory + : undefined); if (!filePath) return undefined; const decision = evaluatePathCapability(filePath, toolActionForFsTool(toolName), context, { @@ -109,9 +113,9 @@ function toolActionForFsTool(toolName: string): 'read' | 'write' { /** Extract the primary file path from a tool's input object. */ function extractFilePath(input: Record): string | undefined { - if (typeof input.file_path === 'string') return input.file_path; - if (typeof input.filePath === 'string') return input.filePath; - if (typeof input.path === 'string') return input.path; - if (typeof input.pattern === 'string') return input.pattern; + if (typeof input.file_path === 'string' && input.file_path) return input.file_path; + if (typeof input.filePath === 'string' && input.filePath) return input.filePath; + if (typeof input.path === 'string' && input.path) return input.path; + // A search pattern is not a filesystem path. Directory tools default to cwd. return undefined; } diff --git a/packages/agent-modules/permission/src/tools/fs-permission.ts b/packages/agent-modules/permission/src/tools/fs-permission.ts index 7abcd860d..a31dca6a1 100644 --- a/packages/agent-modules/permission/src/tools/fs-permission.ts +++ b/packages/agent-modules/permission/src/tools/fs-permission.ts @@ -650,6 +650,54 @@ export function isTempDirectory(resolvedPath: string): boolean { // Internal whitelist paths // --------------------------------------------------------------------------- +/** Resolve existing parents too, so a directory alias cannot hide a protected read. */ +function resolveExistingPath(filePath: string): string { + try { + return fs.realpathSync(filePath); + } catch { + const parent = path.dirname(filePath); + return parent === filePath + ? filePath + : path.join(resolveExistingPath(parent), path.basename(filePath)); + } +} + +/** Runtime state is private; only designated agent assets get implicit read access. */ +function isProtectedRuntimeRead( + filePath: string, + context: PathCheckContext, + recursive: boolean, +): boolean { + const within = (target: string, root: string) => { + const relative = path.relative(root, target); + return ( + relative === '' || + (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)) + ); + }; + const roots = [context.dataDir, path.join(context.homeDir ?? os.homedir(), '.minimax')]; + const canonicalPath = resolveExistingPath(filePath); + for (const root of roots) { + if (!root) continue; + const resolvedRoot = path.resolve(root); + const canonicalRoot = resolveExistingPath(resolvedRoot); + if (recursive && within(canonicalRoot, canonicalPath)) return true; + if (!within(filePath, resolvedRoot) && !within(canonicalPath, canonicalRoot)) continue; + // Do not resolve the asset subdirectory itself: a skills/ symlink pointing + // at credentials must not turn its destination into a trusted asset root. + const readableAsset = + within(canonicalPath, path.join(canonicalRoot, 'skills')) || + (context.agentName != null && + within(canonicalPath, path.join(canonicalRoot, 'agents', context.agentName, 'workspace'))) || + isInternalWhitelistedPath(canonicalPath, { + ...context, + dataDir: canonicalRoot, + }); + if (!readableAsset) return true; + } + return false; +} + /** * Check if a path is in the internal whitelist (always writable by the system). * @@ -835,6 +883,24 @@ export function isPathAllowed( } } + // Runtime state can contain credentials and session data. Check before ALL + // implicit allowances, including workspace/temp and internal aliases. A grep + // of the data root must be reviewed just like a direct config.yaml read. + if ( + READ_ONLY_TOOLS.has(toolName) && + isProtectedRuntimeRead(resolved, context, toolName === 'grep') + ) { + return { + allowed: false, + reason: { + type: 'safetyCheck', + description: `Private runtime data access requires approval. Path: ${resolved}`, + category: 'credentialFile', + classifierApprovable: false, + }, + }; + } + // Step 2: Internal path whitelist if (isInternalWhitelistedPath(resolved, context)) { return { diff --git a/packages/agent-modules/permission/src/tools/path-capability.ts b/packages/agent-modules/permission/src/tools/path-capability.ts index c63840d4b..18a8f9b41 100644 --- a/packages/agent-modules/permission/src/tools/path-capability.ts +++ b/packages/agent-modules/permission/src/tools/path-capability.ts @@ -292,6 +292,7 @@ export function evaluatePathCapability( behavior: 'ask', reason: result.reason, bypassImmune: wouldBeBypassImmune, + ...(result.reason.classifierApprovable === false ? { skipAutoClassifier: true } : {}), ...buildPathRuleSuggestion(filePath, action, context), }; } diff --git a/packages/local-runtime/src/permissions/facade.ts b/packages/local-runtime/src/permissions/facade.ts index fa33795b6..a16a8fac0 100644 --- a/packages/local-runtime/src/permissions/facade.ts +++ b/packages/local-runtime/src/permissions/facade.ts @@ -977,51 +977,7 @@ export class LocalPermissionFacade { // Helpers // ------------------------------------------------------------------------- - /** - * Self-paths that mavis auto-allows for READ-ONLY tools without - * prompting. **Write-side tools always go through the normal review - * flow even on these paths.** - * - * Rationale: mavis's own runtime data (`cfg.dataDir`) and the user-wide - * default dataDir (``) hold the runtime's own assets — built-in - * skills (`/skills//SKILL.md`), session transcripts, - * agent configs, generated hooks. The agent READING those is not - * "external filesystem access" in any meaningful sense; it is the - * runtime reading its own application directory. Prompting the user - * for "may the agent read its own SKILL.md?" is noise on par with an - * OS asking "may VS Code read its own settings.json?". - * - * **Writes are deliberately NOT auto-allowed.** The sensitive-file - * safeguards in fs-permission (`isCredentialFile`, `isEnvFile`, - * `isSensitiveGitFile`, `isSystemSensitivePath`) only run on the - * READ side (`fs-permission.ts:818-875` lives under the read-policy - * path). On the WRITE side, `sandboxAllowPaths` would short-circuit - * to allow at `fs-permission.ts:968` BEFORE any credential / .env / - * .git/config check could intervene — letting a default/auto-mode - * agent silently create or overwrite - * `/.aws/credentials`, `/.env`, or - * `/.git/config`. By only injecting the self-paths for - * read-only tools (set parity with `READ_ONLY_TOOLS` = - * `{read, glob, grep, list}`), writes fall through to the normal - * ask flow — the user sees a card for every mavis-internal write, - * which keeps the sensitive-write attack surface unchanged. - * - * What this still does NOT bypass on the read side: - * - `isSystemSensitivePath` / `isCredentialFile` / `isEnvFile` / - * `isInDangerousDirectory + isSensitiveGitFile` checks - * (`fs-permission.ts:818-875`) all run BEFORE the sandbox-allow - * branch (`fs-permission.ts:968`), so a stray - * `/.aws/credentials` or `/.env` still ASKs. - * - The HARD-blocked fs-read registry (ssh / aws / *.pem / Windows - * registry hive / macOS keychain) runs even earlier - * (`path-capability.ts:82`). - * - bypass-immune deny in the facade itself (UNC, root rm). - * - * The defensive filter drops a self-path if it would become a parent of - * `workingDirectory` — if a user accidentally configured their workspace - * inside `/`, we don't want the sandbox allow to silently widen - * the workspace boundary; the normal workspace check covers it. - */ + /** Built-in skill assets may be read without granting access to private runtime state. */ private resolveSandboxAllowPaths(toolName: string, workingDirectory: string): string[] { // Read-only tool set parity with `READ_ONLY_TOOLS` // (`packages/local-runtime/src/permission/tools/fs-permission.ts`). @@ -1033,8 +989,8 @@ export class LocalPermissionFacade { const candidates: string[] = []; const dataDir = this.deps.configGetter().dataDir; - if (dataDir) candidates.push(path.resolve(dataDir)); - candidates.push(path.resolve(homedir(), '.minimax')); + if (dataDir) candidates.push(path.resolve(dataDir, 'skills')); + candidates.push(path.resolve(homedir(), '.minimax', 'skills')); // De-duplicate (dataDir may already equal on a default install). const unique = Array.from(new Set(candidates)); if (!workingDirectory) return unique; @@ -1255,7 +1211,7 @@ export class LocalPermissionFacade { ` 1. Remove ${scriptPath} and restart the app — startup re-seeds the script.\n` + ' 2. If it fails again, verify the runtime data directory is writable and not ' + 'mounted noexec, and that the script keeps its execute bit.\n' + - ' 3. Two app versions sharing one data directory overwrite each other\'s script; ' + + " 3. Two app versions sharing one data directory overwrite each other's script; " + 'close the other version, then restart.', denySource: 'safety-immune', }; diff --git a/packages/local-runtime/test/unit/local-permission-facade.test.ts b/packages/local-runtime/test/unit/local-permission-facade.test.ts index ed6075af6..29ebe6500 100644 --- a/packages/local-runtime/test/unit/local-permission-facade.test.ts +++ b/packages/local-runtime/test/unit/local-permission-facade.test.ts @@ -16,12 +16,8 @@ * facade ↔ gate plumbing is exercised end-to-end. * - localHardCheck.deny (UNC, root rm) is bypass-immune across all * askPolicy and runs BEFORE the gate, so the gate cannot weaken it - * - sandbox self-paths: dataDir + ~/.minimax auto-allow ONLY for - * read-only tools (read/glob/grep/list) so reading a SKILL.md is - * friction-free; writes still go through the normal ask flow - * because fs-permission's credential/.env/.git-sensitive guards - * only run on the read side, never on writes. Even on the read - * side, sensitive files inside dataDir still ASK. + * - designated skill assets remain readable; private runtime state and + * directory-wide searches require approval, including aliases. * - rewrittenInput (rm → mavis-trash) survives the ask branch * - localeHint is derived from inline latestUserMessages * - cloud-gateway timeout / block / confirm route to ask-user with @@ -31,11 +27,15 @@ * - acceptEdits seed adds 3 global rules; idempotent */ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import path from 'node:path'; -import { readWindowsTrashExecution } from '@mavis/permission'; +import { + configurePermissionHost, + readWindowsTrashExecution, + resetPermissionHostForTesting, +} from '@mavis/permission'; import { describe, expect, it, vi } from 'vitest'; import type { CloudClassifyRequest, @@ -67,6 +67,7 @@ function freshFacade( workspaceDir?: string; sandbox?: LocalRuntimeConfig['sandbox']; seedPosixTrash?: boolean; + dataDirParent?: string; } = {}, ): { facade: LocalPermissionFacade; @@ -74,7 +75,7 @@ function freshFacade( pluginHookPermissionStore: LocalPluginHookPermissionStore; dataDir: string; } { - const dataDir = mkdtempSync(path.join(tmpdir(), 'aa-facade-')); + const dataDir = mkdtempSync(path.join(opts.dataDirParent ?? tmpdir(), 'aa-facade-')); const config: LocalRuntimeConfig = { dataDir, permissionMode: opts.permissionMode ?? 'default', @@ -1708,15 +1709,170 @@ describe('LocalPermissionFacade', () => { }); describe('sandbox self-paths (dataDir auto-allow)', () => { - // Reads of mavis's own runtime data (skills, sessions, agent configs) - // are not "external filesystem access" — they're the agent operating - // on its own application directory. The facade injects `cfg.dataDir` - // and `~/.minimax` into `sandboxAllowPaths`; fs-permission's - // step-5 sandbox branch then allows reads and writes of those paths - // without an ask card. + it.each(['read', 'grep', 'glob', 'list'])( + 'keeps private runtime %s requests out of automatic approval', + async (toolName) => { + const classify = vi.fn(async (): Promise => ({ + kind: 'allow', + reasonLocalized: 'ok', + })); + const { facade, dataDir } = freshFacade({ + permissionMode: 'auto', + cloudGateway: { classify }, + }); + configurePermissionHost({ + runtimeConfigProvider: { + getConfig: () => ({}), + getRuntimeRegion: () => 'cn', + getRuntimeBuildEnv: () => 'prod', + isManagedRuntime: () => true, + }, + }); + try { + const result = await facade.checkPermission({ + toolName, + input: { + path: toolName === 'read' ? path.join(dataDir, 'config.yaml') : dataDir, + pattern: '*', + }, + }); + expect(result.behavior).toBe('ask'); + expect(classify).not.toHaveBeenCalled(); + expect(result.hookAutoApprovalEligible).toBeUndefined(); + } finally { + resetPermissionHostForTesting(); + cleanup(dataDir); + } + }, + ); + + it('requires approval for aliases to credentials, including internal memory paths', async () => { + const workspaceDir = mkdtempSync(path.join(tmpdir(), 'runtime-alias-workspace-')); + const { facade, dataDir } = freshFacade({ + workspaceDir, + dataDirParent: homedir(), + }); + try { + const configPath = path.join(dataDir, 'config.yaml'); + writeFileSync(configPath, 'synthetic: fixture-only-secret\n'); + mkdirSync(path.join(dataDir, 'memory')); + mkdirSync(path.join(dataDir, 'skills')); + const aliases = [ + path.join(workspaceDir, 'notes.txt'), + path.join(dataDir, 'memory', 'notes.txt'), + path.join(dataDir, 'skills', 'notes.txt'), + ]; + for (const alias of aliases) { + symlinkSync(configPath, alias); + expect( + ( + await facade.checkPermission({ + toolName: 'read', + agentName: 'fixture-agent', + input: { path: alias }, + }) + ).behavior, + ).toBe('ask'); + } + const directoryAlias = path.join(workspaceDir, 'runtime'); + symlinkSync(dataDir, directoryAlias, 'dir'); + expect( + ( + await facade.checkPermission({ + toolName: 'grep', + agentName: 'fixture-agent', + input: { path: directoryAlias, pattern: 'synthetic' }, + }) + ).behavior, + ).toBe('ask'); + } finally { + cleanup(dataDir); + cleanup(workspaceDir); + } + }); + + it.each([true, false])('protects nested runtime data from recursive search (explicit path: %s)', async (explicitPath) => { + const workspaceDir = mkdtempSync(path.join(tmpdir(), 'runtime-parent-workspace-')); + const { facade, dataDir } = freshFacade({ + workspaceDir, + dataDirParent: workspaceDir, + }); + try { + const result = await facade.checkPermission({ + toolName: 'grep', + agentName: 'fixture-agent', + input: { ...(explicitPath ? { path: workspaceDir } : {}), pattern: 'secret' }, + }); + expect(result.behavior).toBe('ask'); + } finally { + cleanup(dataDir); + cleanup(workspaceDir); + } + }); + it('honors an explicit path approval for private runtime data', async () => { + const { facade, ruleStore, dataDir } = freshFacade({ + dataDirParent: homedir(), + }); + try { + const configPath = path.join(dataDir, 'config.yaml'); + await ruleStore.applyUpdate({ + type: 'addRules', + source: 'global', + destination: 'global', + behavior: 'allow', + rules: [{ tool_name: 'read', rule_content: configPath }], + }); + expect( + ( + await facade.checkPermission({ + toolName: 'read', + input: { path: configPath }, + }) + ).behavior, + ).toBe('allow'); + } finally { + cleanup(dataDir); + } + }); + it('keeps the active agent workspace readable when it is stored under runtime data', async () => { + const { facade, dataDir } = freshFacade({ dataDirParent: homedir() }); + try { + const result = await facade.checkPermission({ + toolName: 'read', + agentName: 'fixture-agent', + input: { path: path.join(dataDir, 'agents', 'fixture-agent', 'workspace', 'report.txt') }, + }); + expect(result.behavior).toBe('allow'); + } finally { + cleanup(dataDir); + } + }); + it.each([tmpdir(), homedir()])( + 'requires approval before reading the runtime credential configuration under %s', + async (dataDirParent) => { + const { facade, dataDir } = freshFacade({ + permissionMode: 'default', + workspaceDir: '/synthetic-workspace', + dataDirParent, + }); + try { + writeFileSync(path.join(dataDir, 'config.yaml'), 'synthetic: fixture-only-secret\n'); + const result = await facade.checkPermission({ + toolName: 'read', + input: { path: path.join(dataDir, 'config.yaml') }, + }); + expect(result.behavior).toBe('ask'); + } finally { + cleanup(dataDir); + } + }, + ); it('default mode: read of a SKILL.md inside dataDir auto-allows (no card)', async () => { - const { facade, dataDir } = freshFacade({ permissionMode: 'default' }); + const { facade, dataDir } = freshFacade({ + permissionMode: 'default', + dataDirParent: homedir(), + }); try { const r = await facade.checkPermission({ toolName: 'read', @@ -1786,12 +1942,12 @@ describe('LocalPermissionFacade', () => { } }); - it('glob inside dataDir auto-allows (read-only sibling of read)', async () => { + it('glob inside the skill assets auto-allows', async () => { const { facade, dataDir } = freshFacade({ permissionMode: 'default' }); try { const r = await facade.checkPermission({ toolName: 'glob', - input: { path: dataDir, pattern: 'skills/*/SKILL.md' }, + input: { path: path.join(dataDir, 'skills'), pattern: '*/SKILL.md' }, }); // glob/grep/list share the read-only tool set with `read`; they // should auto-allow on self-paths for the same reason. diff --git a/packages/tui/src/application/assistant-content.ts b/packages/tui/src/application/assistant-content.ts index 5a9085e7e..c4363b121 100644 --- a/packages/tui/src/application/assistant-content.ts +++ b/packages/tui/src/application/assistant-content.ts @@ -4,6 +4,7 @@ import { type DeliverAssetItem, type DeliverAssetsSegment, } from '@mavis/shared/asset-markup'; +import { sanitizeTerminalText } from '../tui/rendering/terminal-text.js'; const RICH_CONTENT_RE = /<(?:deliver[-_]assets|media|publish[_-]artifact|preview_card|genui-|mavis-widget|mavis-thinking|mavis-progress|think|final|permission-(?:ask|response)|questionnaire-(?:ask|response))\b|^\s*preview_cards?:\s*$/imu; @@ -25,6 +26,8 @@ export function simplifyAssistantContentForTerminal(content: string): string { } export function projectAssistantContentForTerminal(content: string): TerminalAssistantContent { + // Model content is data. Only the renderer may introduce terminal controls. + content = sanitizeTerminalText(content); if (!content || !RICH_CONTENT_RE.test(content)) return { text: content, assets: [] }; const segments = splitMarkdownProtectedSegments(content); diff --git a/packages/tui/src/tui/features/transcript/panel.ts b/packages/tui/src/tui/features/transcript/panel.ts index a02fdc474..785f164ef 100644 --- a/packages/tui/src/tui/features/transcript/panel.ts +++ b/packages/tui/src/tui/features/transcript/panel.ts @@ -15,6 +15,7 @@ import { VStack, } from '../../engine/public.js'; import { projectAssistantContentForTerminal } from '../../../application/assistant-content.js'; +import { sanitizeTerminalText } from '../../rendering/terminal-text.js'; import type { Component, Focusable } from '../../rendering/component.js'; import { truncateToWidth, visibleWidth } from '../../rendering/text.js'; import { renderTuiStructuredPreview } from '../../transcript/presentation/structured-preview.js'; @@ -564,7 +565,9 @@ export class TuiTranscriptPanel implements TuiFeatureScreen, Component, Focusabl ): string[] { const lines: string[] = []; if (label) lines.push(this.detailLine(chalk.bold.hex(colors.dim)(label), contentWidth + 4)); - const rendered = new Text(chalk.hex(colors.muted)(value), 0, 0).render(contentWidth); + const rendered = new Text(chalk.hex(colors.muted)(sanitizeTerminalText(value)), 0, 0).render( + contentWidth, + ); lines.push(...rendered.map((line) => this.detailLine(line, contentWidth + 4))); return lines; } diff --git a/packages/tui/src/tui/rendering/terminal-text.ts b/packages/tui/src/tui/rendering/terminal-text.ts index b2e05a345..a76fe2865 100644 --- a/packages/tui/src/tui/rendering/terminal-text.ts +++ b/packages/tui/src/tui/rendering/terminal-text.ts @@ -1,8 +1,14 @@ import { stripVTControlCharacters } from 'node:util'; export function sanitizeTerminalText(value: string): string { - return stripVTControlCharacters(value).replace( - /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, + // Consume terminal strings, including incomplete streaming prefixes and C1 + // forms, before stripping ordinary ANSI styles. Their payload is not prose. + const text = value.replace( + /(?:\u001b\]|\u009d)[\s\S]*?(?:\u0007|\u001b\\|\u009c|$)|(?:\u001b[P^_X]|[\u0090\u0098\u009e\u009f])[\s\S]*?(?:\u001b\\|\u009c|$)/gu, '', ); + return stripVTControlCharacters(text) + .replace(/\r\n/gu, '\n') + .replace(/\r/gu, ' ') + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/gu, ''); } diff --git a/packages/tui/src/tui/transcript/view.ts b/packages/tui/src/tui/transcript/view.ts index 78c087bb8..869867350 100644 --- a/packages/tui/src/tui/transcript/view.ts +++ b/packages/tui/src/tui/transcript/view.ts @@ -990,7 +990,7 @@ function boundThinkingPreview(detail: string, edge: 'head' | 'tail'): string { function renderExecutionDetail(detail: string, width: number, rawPrefix = ' │ '): string[] { const prefix = chalk.hex(colors.dim)(rawPrefix); const contentWidth = Math.max(1, width - visibleLength(rawPrefix)); - return new Text(chalk.hex(colors.muted)(detail), 0, 0) + return new Text(chalk.hex(colors.muted)(sanitizeTerminalText(detail)), 0, 0) .render(contentWidth) .map((line) => truncateToWidth(`${prefix}${line}`, Math.max(0, width), '')); } diff --git a/packages/tui/test/unit/tui-thinking-preview.test.ts b/packages/tui/test/unit/tui-thinking-preview.test.ts index 7ab77738a..41235c4f4 100644 --- a/packages/tui/test/unit/tui-thinking-preview.test.ts +++ b/packages/tui/test/unit/tui-thinking-preview.test.ts @@ -16,6 +16,31 @@ function thinkingStore(content: string, status: 'running' | 'failed' | 'succeede } describe('Thinking preview', () => { + it.each(['assistant', 'thinking'] as const)( + 'sanitizes %s in the expanded transcript inspector', + (kind) => { + const control = '\x1b]52;c;U1lOVEhFVElD\x07'; + const store = new TranscriptStore([ + createTranscriptCell({ + id: 'untrusted-inspector', + kind, + status: 'succeeded', + content: `${control}\x1b[8mVisible text\x1b[0m`, + createdAtMs: 1, + }), + ]); + const panel = new TuiTranscriptPanel({ + source: store, + onCancel: () => undefined, + requestRender: () => undefined, + }); + panel.handleInput('\u000f'); + const rendered = panel.render(100).join('\n'); + expect(rendered).not.toContain(control); + expect(rendered).not.toContain('\x1b[8m'); + expect(stripVTControlCharacters(rendered)).toContain('Visible text'); + }, + ); afterEach(() => vi.restoreAllMocks()); it('bounds colored layout work while new deltas stream into the same full cell', () => { diff --git a/packages/tui/test/unit/tui-transcript-view.test.ts b/packages/tui/test/unit/tui-transcript-view.test.ts index 5d96c2aec..e6e0f6044 100644 --- a/packages/tui/test/unit/tui-transcript-view.test.ts +++ b/packages/tui/test/unit/tui-transcript-view.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest'; import { TranscriptView, resolveTuiDecisionColor } from '../../src/tui/transcript/view.js'; import { createTranscriptCell } from '../../src/tui/transcript/model.js'; import { TranscriptStore } from '../../src/tui/transcript/store.js'; +import { sanitizeTerminalText } from '../../src/tui/rendering/terminal-text.js'; import { applyTuiRenderTheme, createTuiChalk, @@ -20,6 +21,57 @@ const previewDisplayModes = { }; describe('TranscriptView', () => { + it.each(['assistant', 'assistant-preamble', 'thinking'] as const)( + 'removes control strings from %s content without changing stored model output', + (kind) => { + const control = '\x1b]52;c;U1lOVEhFVElD\x07'; + const content = `**Visible**\n\n\`\`\`text\n${control}\x1b[8mSafe code\x1b[0m\n\`\`\``; + const cell = createTranscriptCell({ + id: 'untrusted-markdown', + kind, + status: 'running', + content, + createdAtMs: 1, + }); + const view = new TranscriptView(() => [cell], { + displayModes: { revision: 0, resolveMainDisplayMode: () => 'expanded' }, + }); + const rendered = view.render(100).join('\n'); + expect(rendered).not.toContain(control); + expect(rendered).not.toContain('\x1b[8m'); + expect(stripVTControlCharacters(rendered)).toContain('Safe code'); + expect(cell.content).toBe(content); + }, + ); + + it('safely renders every streaming prefix including C1 and unterminated control strings', () => { + const content = 'Visible\x9d52;c;U1lOVEhFVElD\x9c\x1bPprivate payload\x1b\\\x1b[8mtext\x1b[0m'; + for (let end = 1; end <= content.length; end++) { + const text = sanitizeTerminalText(content.slice(0, end)); + expect(text).not.toMatch(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/u); + expect(text).not.toContain('private payload'); + expect(text).not.toContain('U1lOVEhFVElD'); + } + expect(sanitizeTerminalText('中文🙂\ttext\r\nnext\rother')).toBe('中文🙂\ttext\nnext other'); + }); + it.each(['running', 'succeeded'] as const)( + 'does not emit model terminal controls from a %s assistant cell', + (status) => { + const controls = ['\x1b]52;c;U1lOVEhFVElD\x07', '\x1b]2;FORGED_TITLE\x07', '\x1b[8m']; + const view = new TranscriptView(() => [ + createTranscriptCell({ + id: 'untrusted-controls', + kind: 'assistant', + status, + content: `Visible ${controls.join('')}model text\x1b[0m`, + createdAtMs: 1, + }), + ]); + const rendered = view.render(100).join('\n'); + for (const control of controls) expect(rendered).not.toContain(control); + expect(stripVTControlCharacters(rendered)).toContain('Visible model text'); + }, + ); it('renders a Markdown question receipt (Plan Review) as formatted prose', () => { const view = new TranscriptView(() => [ createTranscriptCell({ diff --git a/test/byok.test.mjs b/test/byok.test.mjs index 28943f245..d829a8f13 100644 --- a/test/byok.test.mjs +++ b/test/byok.test.mjs @@ -23,7 +23,10 @@ test( "BYOK runs without managed login and resumes its saved conversation", { timeout: 90000 }, async (t) => { - const dataDir = mkdtempSync(path.join(tmpdir(), "minimax-code-byok-")); + const fixtureDir = mkdtempSync(path.join(tmpdir(), "minimax-code-byok-")); + const dataDir = path.join(fixtureDir, "data"); + const workspaceDir = path.join(fixtureDir, "workspace"); + mkdirSync(workspaceDir); const dbPath = path.join(dataDir, "v2", "sqlite", "runtime-state.sqlite"); mkdirSync(path.dirname(dbPath), { recursive: true }); const legacyDb = new Database(dbPath); @@ -38,7 +41,7 @@ test( } const requests = []; const readMarker = `ACTUAL_FILE_CONTENT_${Date.now()}`; - writeFileSync(path.join(dataDir, "read-fixture.txt"), readMarker); + writeFileSync(path.join(workspaceDir, "read-fixture.txt"), readMarker); let toolRequested = false; let rejectConnection = false; const networkAudit = path.join(dataDir, "network-audit.log"); @@ -97,7 +100,7 @@ test( function: { name: "read", arguments: JSON.stringify({ - path: path.join(dataDir, "read-fixture.txt"), + path: path.join(workspaceDir, "read-fixture.txt"), }), }, }, @@ -148,7 +151,7 @@ test( : "Unexpected external request", ); } finally { - rmSync(dataDir, { recursive: true, force: true }); + rmSync(fixtureDir, { recursive: true, force: true }); } }); const baseUrl = `http://127.0.0.1:${server.address().port}/v1`; @@ -173,7 +176,7 @@ test( : args; return new Promise((resolve, reject) => { const child = spawn(process.execPath, [cli, ...commandArgs], { - cwd: dataDir, + cwd: workspaceDir, env: { ...withoutProxyEnvironment(environment), ...env }, stdio: ["ignore", "pipe", "pipe"], });