Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/codex-headless
17 changes: 17 additions & 0 deletions src/main/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ export type ManagerEvents = {
options?: string[]
selectedIndex?: number
}]
'permission-prompt': [{
sessionId: string
visible: boolean
title?: string
toolName?: string
command?: string
options?: Array<{ key: string; label: string }>
selectedIndex?: number
}]
'compaction-state': [{
sessionId: string
visible: boolean
Expand Down Expand Up @@ -294,6 +303,14 @@ export class SessionManager extends EventEmitter {
options?: string[]
selectedIndex?: number
}) => this.emit('resume-prompt', { sessionId, ...state }))
session.on('permission-prompt', (state: {
visible: boolean
title?: string
toolName?: string
command?: string
options?: Array<{ key: string; label: string }>
selectedIndex?: number
}) => this.emit('permission-prompt', { sessionId, ...state }))
session.on('compaction-state', (state: {
visible: boolean
phase?: 'running' | 'error' | 'done'
Expand Down
1 change: 1 addition & 0 deletions src/main/sessions/forwarder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function wireSessionForwarder(
manager.on('process-state', payload => sendToMainWindow('session:process-state', payload))
manager.on('trust-dialog', payload => sendToMainWindow('session:trust-dialog', payload))
manager.on('resume-prompt', payload => sendToMainWindow('session:resume-prompt', payload))
manager.on('permission-prompt', payload => sendToMainWindow('session:permission-prompt', payload))
manager.on('compaction-state', payload => sendToMainWindow('session:compaction-state', payload))
manager.on('semantic-event', payload => sendToMainWindow('session:semantic-event', payload))
manager.on('exit', payload => {
Expand Down
37 changes: 23 additions & 14 deletions src/main/storage/debugBundle.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { mkdir, writeFile } from 'fs/promises'
import { join } from 'path'
import { dirname, join, normalize } from 'path'

import { DEBUG_BUNDLE_DIR } from '@main/storage/paths.js'

Expand Down Expand Up @@ -29,8 +29,8 @@ import { DEBUG_BUNDLE_DIR } from '@main/storage/paths.js'
// without manual renaming.

export type DebugBundleFile = {
/** Filename relative to the bundle folder. Validated: no path
* separators allowed — the renderer only ships leaf names. */
/** File path relative to the bundle folder. Validated as a
* portable relative path — no absolute paths, no `..` segments. */
name: string
/** Text content. Binary files are not supported because none of
* the debug surfaces produce binary data today (HTML capture is
Expand Down Expand Up @@ -79,14 +79,21 @@ function buildBundleFolderName(sessionId: string, now: Date): string {
return `${stamp}-${short}`
}

// Validate that a file name is a plain leaf and doesn't try to
// escape the bundle folder. The renderer is in-process and trusted,
// but the discipline costs one line and makes future refactors
// (e.g. surfacing this IPC to a plugin) safe by default.
function isSafeFileName(name: string): boolean {
if (!name || name.length > 255) return false
if (name.includes('/') || name.includes('\\')) return false
if (name === '.' || name === '..') return false
// Validate that a bundle file path stays inside the timestamped
// folder. Root-level debug files still pass (`manifest.json`), and
// trace files can now live in controlled subfolders
// (`trace/html/commits.jsonl`). Absolute paths, empty segments, and
// `..` are rejected.
function isSafeRelativePath(name: string): boolean {
if (!name || name.length > 512) return false
if (name.startsWith('/') || name.startsWith('\\')) return false
if (/^[a-zA-Z]:[\\/]/.test(name)) return false
const rawParts = name.split(/[\\/]/)
if (rawParts.some(part => !part || part === '.' || part === '..' || part.length > 255)) {
return false
}
const normalized = normalize(name)
if (normalized.startsWith('..')) return false
return true
}

Expand All @@ -97,8 +104,8 @@ export async function saveDebugBundle(
throw new Error('saveDebugBundle: missing sessionId or empty files list')
}
for (const file of params.files) {
if (!isSafeFileName(file.name)) {
throw new Error(`saveDebugBundle: unsafe file name: ${JSON.stringify(file.name)}`)
if (!isSafeRelativePath(file.name)) {
throw new Error(`saveDebugBundle: unsafe file path: ${JSON.stringify(file.name)}`)
}
}

Expand All @@ -116,7 +123,9 @@ export async function saveDebugBundle(
// on when debugging cc-shell itself (which is the whole point of
// this feature).
for (const file of params.files) {
await writeFile(join(bundlePath, file.name), file.content, 'utf8')
const target = join(bundlePath, file.name)
await mkdir(dirname(target), { recursive: true })
await writeFile(target, file.content, 'utf8')
}

return { bundlePath }
Expand Down
4 changes: 4 additions & 0 deletions src/preload/api/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
SessionJsonlEntriesEvent,
SessionJsonlEntryEvent,
SessionJsonlErrorEvent,
SessionPermissionPromptEvent,
SessionKind,
SessionScreenEvent,
SessionSemanticEvent,
Expand Down Expand Up @@ -140,6 +141,9 @@ export const sessionApi = {
onSessionResumePrompt: (cb: (e: SessionResumePromptEvent) => void): Unsub =>
subscribe('session:resume-prompt', cb),

onSessionPermissionPrompt: (cb: (e: SessionPermissionPromptEvent) => void): Unsub =>
subscribe('session:permission-prompt', cb),

onSessionCompactionState: (cb: (e: SessionCompactionStateEvent) => void): Unsub =>
subscribe('session:compaction-state', cb),

Expand Down
9 changes: 9 additions & 0 deletions src/preload/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ export type SessionResumePromptEvent = {
options?: string[]
selectedIndex?: number
}
export type SessionPermissionPromptEvent = {
sessionId: string
visible: boolean
title?: string
toolName?: string
command?: string
options?: Array<{ key: string; label: string }>
selectedIndex?: number
}
export type SessionCompactionStateEvent = {
sessionId: string
visible: boolean
Expand Down
1 change: 1 addition & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export type {
SessionJsonlEntriesEvent,
SessionJsonlEntryEvent,
SessionJsonlErrorEvent,
SessionPermissionPromptEvent,
SessionKind,
SessionResumePromptEvent,
SessionScreenEvent,
Expand Down
123 changes: 123 additions & 0 deletions src/providers/claude/renderer/PermissionPromptModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { useEffect } from 'react'

type PermissionPromptState = {
title?: string
toolName?: string
command?: string
options?: Array<{ key: string; label: string }>
selectedIndex?: number
}

type Props = {
state: PermissionPromptState | null
onSend: (data: string) => Promise<void>
}

export function PermissionPromptModal({ state, onSend }: Props) {
useEffect(() => {
if (!state) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault()
void onSend('\r')
} else if (event.key === 'Escape') {
event.preventDefault()
void onSend('3\r')
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [onSend, state])

if (!state) return null

const approve = () => { void onSend('\r') }
const deny = () => { void onSend('3\r') }
const title = state.title ?? 'Claude is requesting permission'

return (
<div
role="dialog"
aria-modal="true"
className="
modal-fade
fixed inset-0 z-[1000]
flex items-center justify-center
bg-canvas/80 backdrop-blur-sm
"
>
<div
className="
modal-pop
w-[520px] max-w-[calc(100vw-64px)]
bg-surface border border-border-hi
p-6
"
>
<div className="flex items-start gap-3 mb-4">
<div className="text-accent text-[18px] leading-none select-none pt-0.5">!</div>
<div>
<div className="text-[14px] font-semibold text-ink leading-[1.3]">
{title}
</div>
{state.toolName && (
<div className="mt-1 text-[11px] uppercase tracking-[0.14em] text-muted">
{state.toolName}
</div>
)}
</div>
</div>

<div className="text-[12px] leading-[1.65] text-ink-dim pl-6">
{state.command && (
<pre className="bg-code-bg text-accent px-3 py-2 mb-3 overflow-x-auto whitespace-pre-wrap text-[11.5px]">
{state.command}
</pre>
)}
{state.options && state.options.length > 0 && (
<div className="space-y-1 text-[11.5px] text-muted">
{state.options.map((option, index) => (
<div
key={`${option.key}:${option.label}`}
className={index === state.selectedIndex ? 'text-ink' : undefined}
>
{option.key}. {option.label}
</div>
))}
</div>
)}
</div>

<div className="flex justify-end gap-2 mt-6 pl-6">
<button
type="button"
onClick={deny}
className="
px-4 py-1.5 text-[12px]
bg-transparent text-ink-dim
border border-border
hover:border-border-hi hover:text-ink
transition-colors duration-120
"
>
deny
</button>
<button
type="button"
onClick={approve}
autoFocus
className="
px-4 py-1.5 text-[12px] font-semibold
bg-accent text-accent-fg
border border-accent
hover:brightness-110
transition-all duration-120
"
>
approve
</button>
</div>
</div>
</div>
)
}
5 changes: 5 additions & 0 deletions src/providers/claude/runtime/claudeSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
terminalToMarkdown,
type CompactionState,
type JsonlEntry,
type PermissionPromptState,
type ProxyServer,
type ResumePromptState,
type SemanticEvent,
Expand Down Expand Up @@ -64,6 +65,7 @@ export type ClaudeSessionEvents = {
'process-state': [{ active: boolean; status?: string }]
'trust-dialog': [TrustDialogState]
'resume-prompt': [ResumePromptState]
'permission-prompt': [PermissionPromptState]
'compaction-state': [CompactionState]
/** New. The flat union of semantic-channel events (see
* claude-code-headless EVENT_SPEC.md). Forwarded verbatim so the
Expand Down Expand Up @@ -313,6 +315,9 @@ export class ClaudeSession extends EventEmitter {
this.headless.on('resume-prompt', state =>
this.emit('resume-prompt', state),
)
this.headless.on('permission-prompt', state =>
this.emit('permission-prompt', state),
)
this.headless.on('compaction-state', state =>
this.emit('compaction-state', state),
)
Expand Down
Loading