feat(hooks): gate orca.yaml execution on trust

Audited and rebased PR 1138. Squashed to remove the original untrusted commit stack; hook execution now requires explicit UI trust or CLI --run-hooks opt-in.
This commit is contained in:
mcd77 2026-04-29 20:29:53 -05:00 committed by GitHub
parent 2329f56180
commit 89f41bfda2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 702 additions and 57 deletions

View File

@ -46,7 +46,8 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
name: getRequiredStringFlag(flags, 'name'),
baseBranch: getOptionalStringFlag(flags, 'base-branch'),
linkedIssue: getOptionalNumberFlag(flags, 'issue'),
comment: getOptionalStringFlag(flags, 'comment')
comment: getOptionalStringFlag(flags, 'comment'),
runHooks: flags.get('run-hooks') === true
})
printResult(result, json, formatWorktreeShow)
},
@ -62,7 +63,8 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
'worktree rm': async ({ flags, client, cwd, json }) => {
const result = await client.call<{ removed: boolean }>('worktree.rm', {
worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client),
force: flags.get('force') === true
force: flags.get('force') === true,
runHooks: flags.get('run-hooks') === true
})
printResult(result, json, (value) => `removed: ${value.removed}`)
}

View File

@ -115,11 +115,11 @@ Common Commands:
orca open [--json]
orca status [--json]
orca worktree list [--repo <selector>] [--limit <n>] [--json]
orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--json]
orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--run-hooks] [--json]
orca worktree show --worktree <selector> [--json]
orca worktree current [--json]
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--json]
orca worktree rm --worktree <selector> [--force] [--json]
orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]
orca worktree ps [--limit <n>] [--json]
orca terminal list [--worktree <selector>] [--limit <n>] [--json]
orca terminal show [--terminal <handle>] [--json]

View File

@ -72,9 +72,12 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
path: ['worktree', 'create'],
summary: 'Create a new Orca-managed worktree',
usage:
'orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'repo', 'name', 'base-branch', 'issue', 'comment'],
notes: ['By default this matches the Orca UI flow and activates the new worktree in the app.']
'orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--run-hooks] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'repo', 'name', 'base-branch', 'issue', 'comment', 'run-hooks'],
notes: [
'By default this matches the Orca UI flow and activates the new worktree in the app.',
'Repo-defined orca.yaml hooks are skipped unless --run-hooks is passed.'
]
},
{
path: ['worktree', 'set'],
@ -86,8 +89,9 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
{
path: ['worktree', 'rm'],
summary: 'Remove a worktree from Orca and git',
usage: 'orca worktree rm --worktree <selector> [--force] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'force']
usage: 'orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'force', 'run-hooks'],
notes: ['Repo-defined orca.yaml archive hooks are skipped unless --run-hooks is passed.']
},
{
path: ['worktree', 'ps'],

View File

@ -524,6 +524,55 @@ describe('registerWorktreeHandlers', () => {
})
})
it('runs the archive hook on remove when skipArchive is not set', async () => {
listWorktreesMock.mockResolvedValue([])
removeWorktreeMock.mockResolvedValue(undefined)
getEffectiveHooksMock.mockReturnValue({
scripts: {
archive: 'echo archived'
}
})
runHookMock.mockResolvedValue({ success: true, output: '' })
await handlers['worktrees:remove'](null, {
worktreeId: 'repo-1::/workspace/feature-wt'
})
expect(runHookMock).toHaveBeenCalledWith(
'archive',
'/workspace/feature-wt',
expect.objectContaining({ id: 'repo-1' })
)
expect(removeWorktreeMock).toHaveBeenCalledWith(
'/workspace/repo',
'/workspace/feature-wt',
false
)
})
it('skips the archive hook on remove when skipArchive is true', async () => {
listWorktreesMock.mockResolvedValue([])
removeWorktreeMock.mockResolvedValue(undefined)
getEffectiveHooksMock.mockReturnValue({
scripts: {
archive: 'echo archived'
}
})
runHookMock.mockResolvedValue({ success: true, output: '' })
await handlers['worktrees:remove'](null, {
worktreeId: 'repo-1::/workspace/feature-wt',
skipArchive: true
})
expect(runHookMock).not.toHaveBeenCalled()
expect(removeWorktreeMock).toHaveBeenCalledWith(
'/workspace/repo',
'/workspace/feature-wt',
false
)
})
it('rejects ask-policy creates before mutating git state when setup decision is missing', async () => {
getEffectiveHooksMock.mockReturnValue({
scripts: {

View File

@ -249,7 +249,7 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
ipcMain.handle(
'worktrees:remove',
async (_event, args: { worktreeId: string; force?: boolean }) => {
async (_event, args: { worktreeId: string; force?: boolean; skipArchive?: boolean }) => {
const { repoId, worktreePath } = parseWorktreeId(args.worktreeId)
const repo = store.getRepo(repoId)
if (!repo) {
@ -273,7 +273,7 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
// Run archive hook before removal
const hooks = getEffectiveHooks(repo)
if (hooks?.scripts.archive) {
if (hooks?.scripts.archive && !args.skipArchive) {
const result = await runHook('archive', worktreePath, repo)
if (!result.success) {
console.error(`[hooks] archive hook failed for ${worktreePath}:`, result.output)

View File

@ -1,7 +1,7 @@
/* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { WorktreeMeta } from '../../shared/types'
import { addWorktree, listWorktrees } from '../git/worktree'
import { addWorktree, listWorktrees, removeWorktree } from '../git/worktree'
import { createSetupRunnerScript, getEffectiveHooks, runHook } from '../hooks'
import { OrchestrationDb } from './orchestration/db'
import { OrcaRuntimeService } from './orca-runtime'
@ -9,6 +9,7 @@ import { OrcaRuntimeService } from './orca-runtime'
const {
MOCK_GIT_WORKTREES,
addWorktreeMock,
removeWorktreeMock,
computeWorktreePathMock,
ensurePathWithinWorkspaceMock,
invalidateAuthorizedRootsCacheMock
@ -23,6 +24,7 @@ const {
}
],
addWorktreeMock: vi.fn(),
removeWorktreeMock: vi.fn(),
computeWorktreePathMock: vi.fn(),
ensurePathWithinWorkspaceMock: vi.fn(),
invalidateAuthorizedRootsCacheMock: vi.fn()
@ -30,7 +32,8 @@ const {
vi.mock('../git/worktree', () => ({
listWorktrees: vi.fn().mockResolvedValue(MOCK_GIT_WORKTREES),
addWorktree: addWorktreeMock
addWorktree: addWorktreeMock,
removeWorktree: removeWorktreeMock
}))
vi.mock('../hooks', () => ({
@ -70,6 +73,7 @@ vi.mock('../git/repo', async (importOriginal) => {
afterEach(() => {
vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES)
vi.mocked(addWorktree).mockReset()
vi.mocked(removeWorktree).mockReset()
vi.mocked(createSetupRunnerScript).mockReset()
vi.mocked(getEffectiveHooks).mockReset()
vi.mocked(runHook).mockReset()
@ -899,7 +903,7 @@ describe('OrcaRuntimeService', () => {
await expect(runtime.searchRepoRefs('id:repo-1', 'main', -5)).rejects.toThrow('invalid_limit')
})
it('returns a setup launch payload for CLI-created worktrees when orca.yaml defines setup', async () => {
it('returns a setup launch payload for CLI-created worktrees when hooks are explicitly enabled', async () => {
const runtime = new OrcaRuntimeService(store)
const activateWorktree = vi.fn()
runtime.setNotifier({
@ -940,7 +944,8 @@ describe('OrcaRuntimeService', () => {
const result = await runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'runtime-hook-test'
name: 'runtime-hook-test',
runHooks: true
})
expect(createSetupRunnerScript).toHaveBeenCalledWith(
@ -973,6 +978,90 @@ describe('OrcaRuntimeService', () => {
expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), result.setup)
})
it('skips setup hooks for CLI-created worktrees by default', async () => {
const runtime = new OrcaRuntimeService(store)
const activateWorktree = vi.fn()
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree,
createTerminal: vi.fn(),
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn()
})
runtime.attachWindow(1)
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-hook-skip')
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-hook-skip')
vi.mocked(getEffectiveHooks).mockReturnValue({
scripts: {
setup: 'pnpm worktree:setup'
}
})
vi.mocked(listWorktrees).mockResolvedValueOnce([
{
path: '/tmp/workspaces/runtime-hook-skip',
head: 'def',
branch: 'runtime-hook-skip',
isBare: false,
isMainWorktree: false
}
])
const result = await runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'runtime-hook-skip'
})
expect(createSetupRunnerScript).not.toHaveBeenCalled()
expect(runHook).not.toHaveBeenCalled()
expect(result).toEqual({
worktree: expect.objectContaining({
repoId: 'repo-1',
path: '/tmp/workspaces/runtime-hook-skip',
branch: 'runtime-hook-skip'
})
})
expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), undefined)
})
it('skips archive hooks for CLI worktree removal by default', async () => {
const runtime = new OrcaRuntimeService(store)
vi.mocked(getEffectiveHooks).mockReturnValue({
scripts: {
archive: 'pnpm worktree:archive'
}
})
vi.mocked(removeWorktree).mockResolvedValue(undefined)
await runtime.removeManagedWorktree(TEST_WORKTREE_ID)
expect(runHook).not.toHaveBeenCalled()
expect(removeWorktree).toHaveBeenCalledWith(TEST_REPO_PATH, TEST_WORKTREE_PATH, false)
})
it('runs archive hooks for CLI worktree removal when hooks are explicitly enabled', async () => {
const runtime = new OrcaRuntimeService(store)
vi.mocked(getEffectiveHooks).mockReturnValue({
scripts: {
archive: 'pnpm worktree:archive'
}
})
vi.mocked(runHook).mockResolvedValue({ success: true, output: '' })
vi.mocked(removeWorktree).mockResolvedValue(undefined)
await runtime.removeManagedWorktree(TEST_WORKTREE_ID, false, true)
expect(runHook).toHaveBeenCalledWith(
'archive',
TEST_WORKTREE_PATH,
expect.objectContaining({ id: TEST_REPO_ID, path: TEST_REPO_PATH })
)
expect(removeWorktree).toHaveBeenCalledWith(TEST_REPO_PATH, TEST_WORKTREE_PATH, false)
})
it('invalidates the filesystem-auth cache after CLI worktree creation', async () => {
// Reproduces: CLI-created worktrees fail with "Access denied: unknown
// repository or worktree path" because the filesystem-auth cache was

View File

@ -949,6 +949,7 @@ export class OrcaRuntimeService {
baseBranch?: string
linkedIssue?: number | null
comment?: string
runHooks?: boolean
}): Promise<CreateWorktreeResult> {
if (!this.store) {
throw new Error('runtime_unavailable')
@ -1035,7 +1036,7 @@ export class OrcaRuntimeService {
let setup: CreateWorktreeResult['setup']
const hooks = getEffectiveHooks(repo)
if (hooks?.scripts.setup) {
if (hooks?.scripts.setup && args.runHooks === true) {
if (this.authoritativeWindowId !== null) {
try {
// Why: CLI-created worktrees must use the same runner-script path as the
@ -1056,6 +1057,9 @@ export class OrcaRuntimeService {
}
})
}
} else if (hooks?.scripts.setup) {
// Runtime RPC calls have no renderer trust prompt, so hooks require explicit CLI opt-in.
console.info(`[hooks] setup hook skipped for ${worktreePath}; pass --run-hooks to run it`)
}
this.notifier?.worktreesChanged(repo.id)
@ -1101,7 +1105,11 @@ export class OrcaRuntimeService {
return mergeWorktree(worktree.repoId, worktree.git, meta)
}
async removeManagedWorktree(worktreeSelector: string, force = false): Promise<void> {
async removeManagedWorktree(
worktreeSelector: string,
force = false,
runHooks = false
): Promise<void> {
if (!this.store) {
throw new Error('runtime_unavailable')
}
@ -1115,11 +1123,14 @@ export class OrcaRuntimeService {
}
const hooks = getEffectiveHooks(repo)
if (hooks?.scripts.archive) {
if (hooks?.scripts.archive && runHooks) {
const result = await runHook('archive', worktree.path, repo)
if (!result.success) {
console.error(`[hooks] archive hook failed for ${worktree.path}:`, result.output)
}
} else if (hooks?.scripts.archive) {
// Runtime RPC calls have no renderer trust prompt, so hooks require explicit CLI opt-in.
console.info(`[hooks] archive hook skipped for ${worktree.path}; pass --run-hooks to run it`)
}
try {

View File

@ -34,7 +34,8 @@ const WorktreeCreate = z.object({
.pipe(z.string().min(1, 'Missing worktree name')),
baseBranch: OptionalString,
linkedIssue: TriStateLinkedIssue,
comment: OptionalString
comment: OptionalString,
runHooks: OptionalBoolean
})
const WorktreeSet = WorktreeSelector.extend({
@ -44,7 +45,8 @@ const WorktreeSet = WorktreeSelector.extend({
})
const WorktreeRemove = WorktreeSelector.extend({
force: OptionalBoolean
force: OptionalBoolean,
runHooks: OptionalBoolean
})
export const WORKTREE_METHODS: RpcMethod[] = [
@ -74,7 +76,8 @@ export const WORKTREE_METHODS: RpcMethod[] = [
name: params.name,
baseBranch: params.baseBranch,
linkedIssue: params.linkedIssue,
comment: params.comment
comment: params.comment,
runHooks: params.runHooks === true
})
}),
defineMethod({
@ -92,7 +95,11 @@ export const WORKTREE_METHODS: RpcMethod[] = [
name: 'worktree.rm',
params: WorktreeRemove,
handler: async (params, { runtime }) => {
await runtime.removeManagedWorktree(params.worktree, params.force === true)
await runtime.removeManagedWorktree(
params.worktree,
params.force === true,
params.runHooks === true
)
return { removed: true }
}
})

View File

@ -342,7 +342,7 @@ export type PreloadApi = {
headRefName?: string
isCrossRepository?: boolean
}) => Promise<{ baseBranch: string } | { error: string }>
remove: (args: { worktreeId: string; force?: boolean }) => Promise<void>
remove: (args: { worktreeId: string; force?: boolean; skipArchive?: boolean }) => Promise<void>
updateMeta: (args: { worktreeId: string; updates: Partial<WorktreeMeta> }) => Promise<Worktree>
persistSortOrder: (args: { orderedIds: string[] }) => Promise<void>
onChanged: (callback: (data: { repoId: string }) => void) => () => void

View File

@ -265,7 +265,7 @@ const api = {
}): Promise<{ baseBranch: string } | { error: string }> =>
ipcRenderer.invoke('worktrees:resolvePrBase', args),
remove: (args: { worktreeId: string; force?: boolean }): Promise<void> =>
remove: (args: { worktreeId: string; force?: boolean; skipArchive?: boolean }): Promise<void> =>
ipcRenderer.invoke('worktrees:remove', args),
updateMeta: (args: {

View File

@ -113,6 +113,9 @@ export function useRemoteRepo(
const state = useAppStore.getState()
const existingIdx = state.repos.findIndex((r) => r.id === repo.id)
if (existingIdx !== -1) {
state.clearOrcaHookTrustForRepo(repo.id)
}
if (existingIdx === -1) {
useAppStore.setState({ repos: [...state.repos, repo] })
} else {

View File

@ -0,0 +1,107 @@
import React, { useCallback } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useAppStore } from '@/store'
import type { OrcaHookScriptKind } from '@/lib/orca-hook-trust'
type ScriptKind = OrcaHookScriptKind
const SCRIPT_KIND_LABEL: Record<ScriptKind, string> = {
setup: 'setup script',
archive: 'archive script',
issueCommand: 'issue command'
}
const SCRIPT_KIND_TRIGGER: Record<ScriptKind, string> = {
setup: 'when this workspace is created',
archive: 'when this workspace is removed',
issueCommand: 'when this workspace launches with a linked issue'
}
const OrcaYamlTrustDialog = React.memo(function OrcaYamlTrustDialog() {
const activeModal = useAppStore((s) => s.activeModal)
const modalData = useAppStore((s) => s.modalData)
const closeModal = useAppStore((s) => s.closeModal)
const markOrcaHookScriptConfirmed = useAppStore((s) => s.markOrcaHookScriptConfirmed)
const isOpen = activeModal === 'confirm-orca-yaml-hooks'
const repoId = typeof modalData.repoId === 'string' ? modalData.repoId : ''
const repoName = typeof modalData.repoName === 'string' ? modalData.repoName : 'this repository'
const scriptKind: ScriptKind =
modalData.scriptKind === 'archive'
? 'archive'
: modalData.scriptKind === 'issueCommand'
? 'issueCommand'
: 'setup'
const scriptContent = typeof modalData.scriptContent === 'string' ? modalData.scriptContent : ''
const contentHash = typeof modalData.contentHash === 'string' ? modalData.contentHash : ''
const onResolve =
typeof modalData.onResolve === 'function'
? (modalData.onResolve as (decision: 'run' | 'skip') => void)
: null
const resolveAndClose = useCallback(
(decision: 'run' | 'skip') => {
if (decision === 'run' && repoId && contentHash) {
markOrcaHookScriptConfirmed(repoId, scriptKind, contentHash)
}
onResolve?.(decision)
closeModal()
},
[closeModal, contentHash, markOrcaHookScriptConfirmed, onResolve, repoId, scriptKind]
)
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
resolveAndClose('skip')
}
},
[resolveAndClose]
)
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md sm:max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-sm">
Run {SCRIPT_KIND_LABEL[scriptKind]} from {repoName}?
</DialogTitle>
<DialogDescription className="text-xs">
This repository&apos;s <code>orca.yaml</code> defines a {SCRIPT_KIND_LABEL[scriptKind]}{' '}
that will execute on your machine {SCRIPT_KIND_TRIGGER[scriptKind]}. Only run it if you
trust the contents of this repository.
</DialogDescription>
</DialogHeader>
{scriptContent && (
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2">
<div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{scriptKind} script
</div>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all font-mono text-xs text-foreground">
{scriptContent}
</pre>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => resolveAndClose('skip')}>
Don&apos;t run
</Button>
<Button onClick={() => resolveAndClose('run')}>Run hooks</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
})
export default OrcaYamlTrustDialog

View File

@ -12,6 +12,7 @@ import DeleteWorktreeDialog from './DeleteWorktreeDialog'
import NonGitFolderDialog from './NonGitFolderDialog'
import RemoveFolderDialog from './RemoveFolderDialog'
import AddRepoDialog from './AddRepoDialog'
import OrcaYamlTrustDialog from './OrcaYamlTrustDialog'
const MIN_WIDTH = 220
const MAX_WIDTH = 500
@ -70,6 +71,7 @@ function Sidebar(): React.JSX.Element {
<NonGitFolderDialog />
<RemoveFolderDialog />
<AddRepoDialog />
<OrcaYamlTrustDialog />
</TooltipProvider>
)
}

View File

@ -34,6 +34,7 @@ import {
type LinkedWorkItemSummary
} from '@/lib/new-workspace'
import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions'
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
export type UseComposerStateOptions = {
initialRepoId?: string
@ -979,12 +980,21 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setCreateError(null)
setCreating(true)
try {
const result = await createWorktree(
repoId,
workspaceName,
baseBranch,
(resolvedSetupDecision ?? 'inherit') as SetupDecision
)
const setupTrustDecision = await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup')
const effectiveSetupDecision: SetupDecision =
setupTrustDecision === 'skip'
? 'skip'
: ((resolvedSetupDecision ?? 'inherit') as SetupDecision)
let issueCommandTrustDecision: 'run' | 'skip' = 'run'
if (shouldRunIssueAutomation) {
issueCommandTrustDecision =
setupTrustDecision === 'skip'
? 'skip'
: await ensureHooksConfirmed(useAppStore.getState(), repoId, 'issueCommand')
}
const result = await createWorktree(repoId, workspaceName, baseBranch, effectiveSetupDecision)
const worktree = result.worktree
await applyWorktreeMeta(worktree.id, {
@ -993,14 +1003,15 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
...(note.trim() ? { comment: note.trim() } : {})
})
const issueCommand = shouldRunIssueAutomation
? {
command: renderIssueCommandTemplate(issueCommandTemplate, {
issueNumber: parsedLinkedIssueNumber,
artifactUrl: linkedWorkItem?.url ?? null
})
}
: undefined
const issueCommand =
shouldRunIssueAutomation && issueCommandTrustDecision === 'run'
? {
command: renderIssueCommandTemplate(issueCommandTemplate, {
issueNumber: parsedLinkedIssueNumber,
artifactUrl: linkedWorkItem?.url ?? null
})
}
: undefined
const startupPlan = buildAgentStartupPlan({
agent: tuiAgent,
prompt: startupPrompt,
@ -1087,11 +1098,17 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setCreateError(null)
setCreating(true)
try {
const trustDecision = await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup')
const effectiveSetupDecision: SetupDecision =
trustDecision === 'skip'
? 'skip'
: ((resolvedSetupDecision ?? 'inherit') as SetupDecision)
const result = await createWorktree(
repoId,
workspaceName,
baseBranch,
(resolvedSetupDecision ?? 'inherit') as SetupDecision
effectiveSetupDecision
)
const worktree = result.worktree

View File

@ -0,0 +1,186 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store/types'
import type { PersistedTrustedOrcaHooks } from '../../../shared/types'
import { __resetTrustPromptChainForTests, ensureHooksConfirmed } from './ensure-hooks-confirmed'
import { hashOrcaHookScript } from './orca-hook-trust'
const hooksCheckMock = vi.fn()
const readIssueCommandMock = vi.fn()
;(globalThis as { window: unknown }).window = {
api: {
hooks: {
check: hooksCheckMock,
readIssueCommand: readIssueCommandMock
}
}
}
type PendingPrompt = {
modal: string
data: Record<string, unknown>
resolve: (decision: 'run' | 'skip') => void
}
function createTestState(overrides?: Partial<AppState>): {
state: AppState
pending: PendingPrompt[]
} {
const pending: PendingPrompt[] = []
const trust: PersistedTrustedOrcaHooks = {}
const state = {
trustedOrcaHooks: trust,
repos: [{ id: 'repo-1', displayName: 'Repo One' }],
openModal: (modal: string, data: Record<string, unknown>) => {
pending.push({ modal, data, resolve: data.onResolve as (d: 'run' | 'skip') => void })
},
...overrides
} as unknown as AppState
return { state, pending }
}
async function flush(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0))
await new Promise((resolve) => setTimeout(resolve, 0))
}
describe('ensureHooksConfirmed', () => {
beforeEach(() => {
hooksCheckMock.mockReset()
readIssueCommandMock.mockReset()
__resetTrustPromptChainForTests()
})
it('short-circuits to run when the persisted content hash matches the current script', async () => {
const { state, pending } = createTestState()
const script = 'pnpm install'
const hash = await hashOrcaHookScript(script)
state.trustedOrcaHooks['repo-1'] = {
setup: { contentHash: hash, approvedAt: 1 }
}
hooksCheckMock.mockResolvedValue({
hasHooks: true,
hooks: { scripts: { setup: script } },
mayNeedUpdate: false
})
const decision = await ensureHooksConfirmed(state, 'repo-1', 'setup')
expect(decision).toBe('run')
expect(pending).toHaveLength(0)
})
it('re-prompts when the script content differs from the persisted hash', async () => {
const { state, pending } = createTestState()
const staleHash = await hashOrcaHookScript('old script')
state.trustedOrcaHooks['repo-1'] = {
setup: { contentHash: staleHash, approvedAt: 1 }
}
hooksCheckMock.mockResolvedValue({
hasHooks: true,
hooks: { scripts: { setup: 'new script' } },
mayNeedUpdate: false
})
const promise = ensureHooksConfirmed(state, 'repo-1', 'setup')
await flush()
expect(pending).toHaveLength(1)
expect(pending[0].data.scriptContent).toBe('new script')
pending[0].resolve('run')
await expect(promise).resolves.toBe('run')
})
it('returns run without prompting when no script of that kind is configured', async () => {
const { state, pending } = createTestState()
hooksCheckMock.mockResolvedValue({
hasHooks: true,
hooks: { scripts: {} },
mayNeedUpdate: false
})
const decision = await ensureHooksConfirmed(state, 'repo-1', 'archive')
expect(decision).toBe('run')
expect(pending).toHaveLength(0)
})
it('returns run without prompting when issueCommand source is local (user-owned)', async () => {
const { state, pending } = createTestState()
readIssueCommandMock.mockResolvedValue({
source: 'local',
sharedContent: null,
localContent: 'user content',
effectiveContent: 'user content',
localFilePath: ''
})
const decision = await ensureHooksConfirmed(state, 'repo-1', 'issueCommand')
expect(decision).toBe('run')
expect(pending).toHaveLength(0)
})
it('opens a modal with the computed content hash and resolves with the user decision', async () => {
const { state, pending } = createTestState()
hooksCheckMock.mockResolvedValue({
hasHooks: true,
hooks: { scripts: { setup: 'pnpm install' } },
mayNeedUpdate: false
})
const promise = ensureHooksConfirmed(state, 'repo-1', 'setup')
await flush()
expect(pending).toHaveLength(1)
expect(pending[0].data).toMatchObject({
repoId: 'repo-1',
repoName: 'Repo One',
scriptKind: 'setup',
scriptContent: 'pnpm install',
contentHash: await hashOrcaHookScript('pnpm install')
})
pending[0].resolve('run')
await expect(promise).resolves.toBe('run')
})
it('serializes overlapping prompts so a second call waits for the first to resolve', async () => {
const { state, pending } = createTestState()
hooksCheckMock.mockResolvedValue({
hasHooks: true,
hooks: { scripts: { setup: 'pnpm install', archive: 'echo bye' } },
mayNeedUpdate: false
})
const first = ensureHooksConfirmed(state, 'repo-1', 'setup')
const second = ensureHooksConfirmed(state, 'repo-1', 'archive')
await flush()
expect(pending).toHaveLength(1)
expect(pending[0].data.scriptKind).toBe('setup')
pending[0].resolve('skip')
await expect(first).resolves.toBe('skip')
await flush()
expect(pending).toHaveLength(2)
expect(pending[1].data.scriptKind).toBe('archive')
pending[1].resolve('run')
await expect(second).resolves.toBe('run')
})
it('fails closed when window.api.hooks.check throws', async () => {
const { state, pending } = createTestState()
hooksCheckMock.mockRejectedValue(new Error('boom'))
const decision = await ensureHooksConfirmed(state, 'repo-1', 'setup')
expect(decision).toBe('skip')
expect(pending).toHaveLength(0)
})
})

View File

@ -0,0 +1,69 @@
import type { AppState } from '@/store/types'
import type { OrcaHooks } from '../../../shared/types'
import { hashOrcaHookScript, type OrcaHookScriptKind } from './orca-hook-trust'
export type HookScriptKind = OrcaHookScriptKind
// Serialize the singleton modal callback so overlapping worktree actions cannot replace it.
let trustPromptChain: Promise<unknown> = Promise.resolve()
function enqueueTrustPrompt<T>(task: () => Promise<T>): Promise<T> {
const next = trustPromptChain.then(task, task)
trustPromptChain = next.catch(() => undefined)
return next
}
export function __resetTrustPromptChainForTests(): void {
trustPromptChain = Promise.resolve()
}
export async function ensureHooksConfirmed(
state: AppState,
repoId: string,
scriptKind: HookScriptKind
): Promise<'run' | 'skip'> {
return enqueueTrustPrompt(async () => {
let scriptContent = ''
try {
if (scriptKind === 'issueCommand') {
// Local overrides are user-owned; only shared orca.yaml commands need repo trust.
const result = await window.api.hooks.readIssueCommand({ repoId })
if (result.source !== 'shared') {
return 'run'
}
scriptContent = (result.sharedContent ?? '').trim()
} else {
const result = await window.api.hooks.check({ repoId })
const yamlHooks = (result.hooks as OrcaHooks | null) ?? null
scriptContent = (yamlHooks?.scripts?.[scriptKind] ?? '').trim()
}
} catch {
// Fail closed: if we cannot inspect the script, we cannot trust it.
return 'skip'
}
if (!scriptContent) {
return 'run'
}
const contentHash = await hashOrcaHookScript(scriptContent)
const existingHash = state.trustedOrcaHooks[repoId]?.[scriptKind]?.contentHash
if (existingHash === contentHash) {
return 'run'
}
const repo = state.repos.find((r) => r.id === repoId)
const repoName = repo?.displayName ?? 'this repository'
return new Promise<'run' | 'skip'>((resolve) => {
state.openModal('confirm-orca-yaml-hooks', {
repoId,
repoName,
scriptKind,
scriptContent,
contentHash,
onResolve: (decision: 'run' | 'skip') => resolve(decision)
})
})
})
}

View File

@ -11,6 +11,7 @@ import {
getWorkspaceSeedName
} from '@/lib/new-workspace'
import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions'
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
import type { OrcaHooks, RepoHookSettings, SetupDecision, TuiAgent } from '../../../shared/types'
export type LaunchableWorkItem = {
@ -164,6 +165,10 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
return
}
const trustDecision = await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup')
const finalSetupDecision: SetupDecision =
trustDecision === 'skip' ? 'skip' : setupResolution.decision
const workspaceName = getWorkspaceSeedName({
explicitName: getLinkedWorkItemSuggestedName(item),
prompt: '',
@ -175,12 +180,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
let primaryTabId: string | null
let startupPlan: ReturnType<typeof buildAgentStartupPlan> = null
try {
const result = await store.createWorktree(
repoId,
workspaceName,
baseBranch,
setupResolution.decision
)
const result = await store.createWorktree(repoId, workspaceName, baseBranch, finalSetupDecision)
worktreeId = result.worktree.id
const detectedIds = new Set(await detectedAgentsPromise)
@ -289,6 +289,10 @@ export async function launchFromBranch(args: LaunchFromBranchArgs): Promise<void
return
}
const trustDecision = await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup')
const finalSetupDecision: SetupDecision =
trustDecision === 'skip' ? 'skip' : setupResolution.decision
// Why: branch-based launches don't carry a title hint, so fall back to the
// repo's creature-name generator — same distinct, readable default the
// quick-composer uses when the name field is blank.
@ -306,12 +310,7 @@ export async function launchFromBranch(args: LaunchFromBranchArgs): Promise<void
})
try {
const result = await store.createWorktree(
repoId,
workspaceName,
baseBranch,
setupResolution.decision
)
const result = await store.createWorktree(repoId, workspaceName, baseBranch, finalSetupDecision)
const detectedIds = new Set(await detectedAgentsPromise)
const effectiveAgent = pickAgent(settings?.defaultTuiAgent, detectedIds)
const startupPlan =

View File

@ -0,0 +1,13 @@
export type OrcaHookScriptKind = 'setup' | 'archive' | 'issueCommand'
export async function hashOrcaHookScript(content: string): Promise<string> {
const normalized = content.trim()
const bytes = new TextEncoder().encode(normalized)
const digest = await crypto.subtle.digest('SHA-256', bytes)
const hex: string[] = []
const view = new Uint8Array(digest)
for (let i = 0; i < view.length; i += 1) {
hex.push(view[i].toString(16).padStart(2, '0'))
}
return hex.join('')
}

View File

@ -69,6 +69,9 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
return null
}
const alreadyAdded = get().repos.some((r) => r.id === repo.id)
if (alreadyAdded) {
get().clearOrcaHookTrustForRepo(repo.id)
}
set((s) => {
if (s.repos.some((r) => r.id === repo.id)) {
return s
@ -103,6 +106,9 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
}
const repo = result.repo
const alreadyAdded = get().repos.some((r) => r.id === repo.id)
if (alreadyAdded) {
get().clearOrcaHookTrustForRepo(repo.id)
}
set((s) => {
if (s.repos.some((r) => r.id === repo.id)) {
return s
@ -139,6 +145,8 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
try {
await window.api.repos.remove({ repoId })
get().clearOrcaHookTrustForRepo(repoId)
// Kill PTYs for all worktrees belonging to this repo
const worktreeIds = (get().worktreesByRepo[repoId] ?? []).map((w) => w.id)
const killedTabIds = new Set<string>()

View File

@ -4,6 +4,7 @@ import type { AppState } from '../types'
import { findPrevLiveWorktreeHistoryIndex } from './worktree-nav-history'
import type {
ChangelogData,
PersistedTrustedOrcaHooks,
PersistedUIState,
StatusBarItem,
TaskViewPresetId,
@ -13,10 +14,9 @@ import type {
} from '../../../../shared/types'
import { PER_REPO_FETCH_LIMIT } from '../../../../shared/work-items'
// Why: mirrors the preset→query mapping in getTaskPresetQuery (new-workspace.ts).
// Why: mirrors the preset→query mapping used by TaskPage's preset buttons.
// Keeping a local copy here avoids a store ↔ lib circular import while letting
// openTaskPage warm exactly the cache key the page will read on mount.
// Must stay in sync with getTaskPresetQuery — see DESIGN-gh-issues-improve.md.
function presetToQuery(presetId: TaskViewPresetId | null): string {
switch (presetId) {
case 'issues':
@ -25,10 +25,10 @@ function presetToQuery(presetId: TaskViewPresetId | null): string {
return 'assignee:@me is:issue is:open'
case 'prs':
return 'is:pr is:open'
case 'my-prs':
return 'author:@me is:pr is:open'
case 'review':
return 'review-requested:@me is:pr is:open'
case 'my-prs':
return 'author:@me is:pr is:open'
default:
return 'is:open'
}
@ -37,6 +37,7 @@ import {
DEFAULT_STATUS_BAR_ITEMS,
DEFAULT_WORKTREE_CARD_PROPERTIES
} from '../../../../shared/constants'
import type { OrcaHookScriptKind } from '../../lib/orca-hook-trust'
const MIN_SIDEBAR_WIDTH = 220
const MAX_LEFT_SIDEBAR_WIDTH = 500
@ -46,6 +47,19 @@ const MAX_LEFT_SIDEBAR_WIDTH = 500
// corrupted/manually-edited values rather than as a product limit.
const MAX_RIGHT_SIDEBAR_WIDTH = 4000
function filterTrustedOrcaHooksToValidRepos(
trust: PersistedTrustedOrcaHooks,
validRepoIds: Set<string>
): PersistedTrustedOrcaHooks {
const next: PersistedTrustedOrcaHooks = {}
for (const [repoId, entry] of Object.entries(trust)) {
if (validRepoIds.has(repoId)) {
next[repoId] = entry
}
}
return next
}
function sanitizePersistedSidebarWidth(width: unknown, fallback: number, maxWidth: number): number {
if (typeof width !== 'number' || !Number.isFinite(width)) {
return fallback
@ -131,6 +145,7 @@ export type UISlice = {
| 'quick-open'
| 'worktree-palette'
| 'new-workspace-composer'
| 'confirm-orca-yaml-hooks'
modalData: Record<string, unknown>
openModal: (modal: UISlice['activeModal'], data?: Record<string, unknown>) => void
closeModal: () => void
@ -145,6 +160,13 @@ export type UISlice = {
* tab every time. */
createFromSubTab: 'prs' | 'issues' | 'branches' | 'linear'
setCreateFromSubTab: (tab: 'prs' | 'issues' | 'branches' | 'linear') => void
trustedOrcaHooks: PersistedTrustedOrcaHooks
markOrcaHookScriptConfirmed: (
repoId: string,
kind: OrcaHookScriptKind,
contentHash: string
) => void
clearOrcaHookTrustForRepo: (repoId: string) => void
searchQuery: string
setSearchQuery: (q: string) => void
groupBy: 'none' | 'repo' | 'pr-status'
@ -333,6 +355,33 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
createFromSubTab: 'prs',
setCreateFromSubTab: (tab) => set({ createFromSubTab: tab }),
trustedOrcaHooks: {},
markOrcaHookScriptConfirmed: (repoId, kind, contentHash) =>
set((s) => {
const existing = s.trustedOrcaHooks[repoId]
const currentEntry = existing?.[kind]
if (currentEntry?.contentHash === contentHash) {
return s
}
const nextRepo = {
...existing,
[kind]: { contentHash, approvedAt: Date.now() }
}
const next = { ...s.trustedOrcaHooks, [repoId]: nextRepo }
window.api.ui.set({ trustedOrcaHooks: next }).catch(console.error)
return { trustedOrcaHooks: next }
}),
clearOrcaHookTrustForRepo: (repoId) =>
set((s) => {
if (!(repoId in s.trustedOrcaHooks)) {
return s
}
const next = { ...s.trustedOrcaHooks }
delete next[repoId]
window.api.ui.set({ trustedOrcaHooks: next }).catch(console.error)
return { trustedOrcaHooks: next }
}),
searchQuery: '',
setSearchQuery: (q) => set({ searchQuery: q }),
@ -449,6 +498,10 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
updateReassuranceSeen: ui.updateReassuranceSeen ?? false,
browserDefaultUrl: ui.browserDefaultUrl ?? null,
browserDefaultSearchEngine: ui.browserDefaultSearchEngine ?? null,
trustedOrcaHooks: filterTrustedOrcaHooksToValidRepos(
ui.trustedOrcaHooks ?? {},
validRepoIds
),
persistedUIReady: true
}
}),

View File

@ -15,6 +15,9 @@ const mockApi = {
},
pty: {
kill: vi.fn().mockResolvedValue(undefined)
},
hooks: {
check: vi.fn().mockResolvedValue({ hasHooks: false, hooks: null, mayNeedUpdate: false })
}
}
@ -30,6 +33,9 @@ function createTestStore() {
// Why: this test isolates the worktree slice, so it only provides the
// state surface that `createWorktreeSlice` reads and writes.
...createWorktreeSlice(...a),
trustedOrcaHooks: {},
repos: [],
openModal: vi.fn(),
shutdownWorktreeTerminals: vi.fn().mockResolvedValue(undefined),
tabsByWorktree: {},
tabBarOrderByWorktree: {},

View File

@ -8,6 +8,7 @@ import {
getRepoIdFromWorktreeId,
type WorktreeSlice
} from './worktree-helpers'
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers'
function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): boolean {
@ -141,12 +142,16 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
}))
try {
const repoIdForTrust = getRepoIdFromWorktreeId(worktreeId)
const trustDecision = await ensureHooksConfirmed(get(), repoIdForTrust, 'archive')
const skipArchive = trustDecision === 'skip'
// Why: setup-enabled worktrees now commonly have a live shell open as soon as
// they are created. We must tear those PTYs down before asking Git to remove
// the working tree or Windows and some shells can keep the directory in use
// and make delete look broken even though the git state itself is fine.
await get().shutdownWorktreeTerminals(worktreeId)
await window.api.worktrees.remove({ worktreeId, force })
await window.api.worktrees.remove({ worktreeId, force, skipArchive })
const tabs = get().tabsByWorktree[worktreeId] ?? []
const tabIds = new Set(tabs.map((t) => t.id))

View File

@ -232,7 +232,8 @@ export function getDefaultUIState(): PersistedUIState {
statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS],
statusBarVisible: true,
dismissedUpdateVersion: null,
lastUpdateCheckAt: null
lastUpdateCheckAt: null,
trustedOrcaHooks: {}
}
}

View File

@ -1117,8 +1117,22 @@ export type PersistedUIState = {
/** Once the user has starred Orca (from any entry point) we permanently
* suppress the nag no further thresholds, no notifications. */
starNagCompleted?: boolean
trustedOrcaHooks?: PersistedTrustedOrcaHooks
}
export type PersistedTrustedOrcaHookEntry = {
contentHash: string
approvedAt: number
}
export type PersistedTrustedOrcaHookRepo = {
setup?: PersistedTrustedOrcaHookEntry
archive?: PersistedTrustedOrcaHookEntry
issueCommand?: PersistedTrustedOrcaHookEntry
}
export type PersistedTrustedOrcaHooks = Record<string, PersistedTrustedOrcaHookRepo>
// ─── Persistence shape ──────────────────────────────────────────────
export type PersistedState = {
schemaVersion: number