A local work board and orchestration loop for Claude Code. Every task saved as a ticket survives context compaction and new sessions, with instructions…

Sidequest is a local board for planning, tracking, and delivering Claude Code work. It keeps a visible backlog across your projects, groups related tickets into stories, and gives Claude a consistent way to delegate, verify, review, and hand work back.
Every task saved as a ticket survives context compaction and new sessions. Its instructions, reproduction steps, status, and recorded discussion stay on the local board, so Claude can retrieve the work after the chat context shrinks.
Setup guide · Generated reference · Toolshed marketplace
Install Sidequest at project scope:
/plugin marketplace add Eigenwise/eigenwise-toolshed
/plugin install sidequest@eigenwise-toolshed --scope project
Reload Claude Code or start a new session. You can also run /quartermaster:setup and let Quartermaster install and configure Sidequest for a project.
Open the board with /sidequest:board, or tell Claude to show it. The dashboard is local and ticket data stays on your machine.
For a small first task, save a checkpoint and read it in a new session before trying a full story.
Tell Claude the outcome you want:
Plan the checkout refresh as a Sidequest story and show me the backlog.
Claude can create the story, split it into tickets, connect dependencies, and classify the work from the board's configured categories. Review the backlog, then ask Claude to dispatch the ready tickets. Claude handles routing, executor startup, verification, and ticket updates. Dispatch uses the model access you configure; any paid subscription or API usage follows that provider's pricing and limits.
When work is ready, ask Claude to review and integrate it:
Review and integrate the submitted checkout tickets if their checks pass.
A codebase ticket hands back a verified revision. Non-Git work hands back a verified project snapshot. Larger or higher-risk changes can get an independent review when the verification evidence needs one.
Ask Claude to save each task as a ticket, including the outcome you want and any reproduction steps. Keep decisions, progress, verification evidence, and the next action in the ticket description or comments as work proceeds. Those records give Claude somewhere to resume after compaction or in another session.
Save this task in Sidequest with the current progress and next steps so we can continue after context compaction.
In the next session, ask:
Check the active Sidequest tickets for this project and continue from their recorded next steps.
Ticket fields and comments persist independently of the conversation. Details that matter for resuming the task need to be recorded on the ticket.
Use the dashboard to switch projects, scan todo, doing, and done work, search and filter tickets, and open full ticket details. You can edit tickets, add comments, connect related work, and set reminders from the board.
Sidequest also works through natural-language requests:
Show me the Sidequest backlog for this project.What is ready to dispatch for the checkout story?Add a ticket for the empty-state bug with the reproduction steps.What is blocking the checkout ticket?Review and integrate the checkout ticket if verification passed.Tell Claude the symptom:
The Sidequest board will not open. Diagnose it.
The checkout ticket will not dispatch. Explain what is blocking it.
A submitted ticket is waiting. Check it and finish the integration if it is safe.
Claude checks the local plugin connection, ticket state, dependencies, configured route, and delivery status, then gives you the next action. After an install or upgrade, start a new Claude Code session or reload plugins so the session picks up the current Sidequest connection and its bundled executors.
For worktree storage, recovery, and command details, see the generated reference. Ask Claude to inspect or clean up local worktrees rather than guessing at lifecycle commands.
Sidequest's plugin code is free and MIT-licensed. If it saves you time, optional donations through Ko-fi or GitHub Sponsors support its maintenance. Donations are never required to install or use the plugin.
MIT (c) Eigenwise
hooks/guards/register.ts 213 lines1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2
3import {
4 deniedToolsDenial, heredocDenial, inlineWorkNudge, inlineWorkStep, isolatedPeerRow, missingWorktreeDenial,
5 repeatedCommandStep, REPEATED_COMMAND_WARNING, taskOutputDenial, terminalPeerDenial, terminalPeerRow,
6} from './context-guards.js'
7import type { InlineWork } from '../../types/index.js'
8import { destructiveGitDenial, sharedCheckoutGitDenial } from './git-guards.js'
9import { registerExecutorAdmission } from './executor-admission.js'
10import { boardExists, caller, machine, projectDirectory, readSnapshot, sessionRows, worktreeExists } from './snapshot.js'
11import { bashWindowsPathVerdict, homeDeleteDenial, powershellShimContext } from './text-guards.js'
12import type { GuardVerdict } from './text-guards.js'
13
14// classic.PreToolUse hands a module only { tool, tool_use_id, ...args } (no agent_type, session_id or cwd),
15// so the guards hook tool.call, whose e also carries agentId inside a subagent (SQ-3743 probe).
16
17const REPEATED_COMMANDS = { plugin: 'sidequest', key: 'repeatedCommands' } as const
18const INLINE_WORK = { plugin: 'sidequest', key: 'inlineWork' } as const
19const AUTOMATION_TAG = /^<(?:agent-message|local-command(?:-caveat)?|task-notification|task-progress|task-result)\b/i
20
21function textField(e: object, name: string): string {
22 const value: unknown = Reflect.get(e, name)
23 return typeof value === 'string' ? value : ''
24}
25
26function withContext(ran: ToolCallResult, context: string | null): ToolCallResult {
27 if (!context || ran.deny !== undefined) return ran
28 return { ...ran, context: [...(ran.context ?? []), context] }
29}
30
31function contextOf(verdict: GuardVerdict): string | null {
32 return verdict && 'context' in verdict ? verdict.context : null
33}
34
35async function isSidequestAgent($: EngineInterface, agentId: string): Promise<boolean> {
36 const agent = (await $.agent.list()).find(candidate => candidate.id === agentId)
37 return /^(?:sidequest:)?sidequest-/.test(agent?.type ?? '')
38}
39
40async function peerMessageDenial($: EngineInterface, to: string): Promise<string | null> {
41 const rows = await sessionRows($)
42 const terminal = terminalPeerRow(rows, to)
43 if (terminal) return terminalPeerDenial(terminal, to)
44 const isolated = isolatedPeerRow(rows, to)
45 return isolated?.worktree && !(await worktreeExists($, isolated.worktree)) ? missingWorktreeDenial(isolated) : null
46}
47
48async function routedBoard($: EngineInterface): Promise<boolean> {
49 const directory = await projectDirectory($, await $.session.cwd())
50 if (!(await boardExists($, directory))) return false
51 return (await readSnapshot($, directory))?.routed !== false
52}
53
54// The main loop only: a subagent's work is its ticket's, and the nudge is about work done without one.
55async function nudgeWanted($: EngineInterface): Promise<boolean> {
56 return (await routedBoard($)) && !(await caller($, undefined))
57}
58
59async function inlineWorkAdvice($: EngineInterface, tool: string, command: string, subagentType: string): Promise<string | null> {
60 const { value = {} } = await $.state.get(INLINE_WORK)
61 const { next, crossed } = inlineWorkStep(value, tool, command, subagentType)
62 if (next !== value) await $.state.set(INLINE_WORK, next)
63 return crossed && (await nudgeWanted($)) ? inlineWorkNudge(next.substantiveActions ?? 0) : null
64}
65
66async function repeatedCommandWarning($: EngineInterface, agentId: string, command: string): Promise<string | null> {
67 if (!(await isSidequestAgent($, agentId))) return null
68 const { value = {} } = await $.state.get(REPEATED_COMMANDS)
69 const step = repeatedCommandStep(value[agentId], command)
70 if (!step) return null
71 await $.state.set(REPEATED_COMMANDS, { ...value, [agentId]: step.next })
72 return step.warn ? REPEATED_COMMAND_WARNING : null
73}
74
75async function deniedToolsGuard($: EngineInterface, e: { tool: string; agentId?: string }): Promise<string | null> {
76 try {
77 return deniedToolsDenial(e.tool, await caller($, e.agentId))
78 } catch {
79 $.ui.status('sidequest: guard-denied-tools skipped')
80 return null
81 }
82}
83
84async function inlineWorkGuard($: EngineInterface, e: { tool: string; agentId?: string }): Promise<string | null> {
85 if (e.agentId) return null
86 try {
87 const advice = await inlineWorkAdvice($, e.tool, textField(e, 'command'), textField(e, 'subagent_type'))
88 if (advice) $.ui.toast(advice)
89 return advice
90 } catch {
91 $.ui.status('sidequest: inline-work-nudge skipped')
92 return null
93 }
94}
95
96export const register: Register = on => {
97 registerExecutorAdmission(on)
98
99 on('tool.call', { tool: /^(Bash|PowerShell)$/ }, async ($, e, next) => {
100 try {
101 const denial = homeDeleteDenial(textField(e, 'command'), await machine($))
102 if (denial) return { deny: denial }
103 } catch {
104 $.ui.status('sidequest: guard-home-delete skipped')
105 }
106 return next(e)
107 })
108
109 // The one guard that fails closed: a reset or push it could not check may destroy or publish work.
110 on('tool.call', { tool: /^(Bash|PowerShell)$/ }, async ($, e, next) => {
111 const command = textField(e, 'command')
112 let denial: string | null
113 try {
114 denial = await destructiveGitDenial($, command)
115 } catch {
116 denial = `sidequest: destructive git guard could not run; refusing ${command.slice(0, 300)}`
117 }
118 return denial ? { deny: denial } : next(e)
119 })
120
121 on('tool.call', { tool: /^(Bash|PowerShell)$/ }, async ($, e, next) => {
122 try {
123 const denial = await sharedCheckoutGitDenial($, textField(e, 'command'), e.agentId)
124 if (denial) return { deny: denial }
125 } catch {
126 $.ui.status('sidequest: guard-shared-checkout-git skipped')
127 }
128 return next(e)
129 })
130
131 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
132 let verdict: GuardVerdict = null
133 try {
134 if ((await machine($)).windows) verdict = bashWindowsPathVerdict(textField(e, 'command'))
135 } catch {
136 $.ui.status('sidequest: guard-bash-windows-paths skipped')
137 }
138 if (verdict && 'deny' in verdict) return { deny: verdict.deny }
139 return withContext(await next(e), contextOf(verdict))
140 })
141
142 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
143 try {
144 const denial = heredocDenial(textField(e, 'command'), await caller($, e.agentId), await $.session.cwd())
145 if (denial) return { deny: denial }
146 } catch {
147 $.ui.status('sidequest: guard-heredoc-isolated skipped')
148 }
149 return next(e)
150 })
151
152 on('tool.call', { tool: 'PowerShell' }, async ($, e, next) => {
153 let context: string | null = null
154 try {
155 if ((await machine($)).windows) context = powershellShimContext(textField(e, 'command'))
156 } catch {
157 $.ui.status('sidequest: guard-powershell-cmd-shims skipped')
158 }
159 return withContext(await next(e), context)
160 })
161
162 on('tool.call', { tool: /^(Bash|PowerShell)$/ }, async ($, e, next) => {
163 let warning: string | null = null
164 try {
165 if (e.agentId) warning = await repeatedCommandWarning($, e.agentId, textField(e, 'command'))
166 } catch {
167 $.ui.status('sidequest: repeated-command-warn skipped')
168 }
169 return withContext(await next(e), warning)
170 })
171
172 // The engine takes one matcher-less tool.call hook per module, so the two every-tool guards share it.
173 on('tool.call', async ($, e, next) => {
174 const denial = await deniedToolsGuard($, e)
175 if (denial) return { deny: denial }
176 return withContext(await next(e), await inlineWorkGuard($, e))
177 })
178
179 on('tool.call', { tool: /^TaskOutput$/ }, async ($, e, next) => {
180 try {
181 const denial = e.agentId ? null : taskOutputDenial({ task_id: Reflect.get(e, 'task_id'), id: Reflect.get(e, 'id') }, await sessionRows($), await $.session.id())
182 if (denial) return { deny: denial }
183 } catch {
184 $.ui.status('sidequest: guard-task-output skipped')
185 }
186 return next(e)
187 })
188
189 on('tool.call', { tool: 'SendMessage' }, async ($, e, next) => {
190 try {
191 const denial = await peerMessageDenial($, textField(e, 'to').trim())
192 if (denial) return { deny: denial }
193 } catch {
194 $.ui.status('sidequest: guard-peer-message skipped')
195 }
196 return next(e)
197 })
198
199 // A board call silences the inline-work nudge only until the next prompt, so a later inline burst is
200 // still counted; advisorySurfaced stays, so the nudge shows once per session.
201 on('prompt.submit', async ($, e, next) => {
202 try {
203 const { value } = await $.state.get(INLINE_WORK)
204 if (!e.isMeta && !AUTOMATION_TAG.test(e.text.trim()) && value?.boardInteraction) {
205 await $.state.set(INLINE_WORK, { advisorySurfaced: value.advisorySurfaced } satisfies InlineWork)
206 }
207 } catch {
208 $.ui.status('sidequest: inline-work-nudge skipped')
209 }
210 return next(e)
211 })
212}
213hooks/guards/context-guards.ts 174 lines1import { deniedToolMatch } from '../../src/lib/denied-tools.js';
2import type { InlineWork, RepeatedCommand } from '../../types/index.js';
3
4// Pure decisions for the guards that need to know who is calling. The caller is resolved from the
5// store's dispatch snapshot (src/lib/store/dispatch-snapshot.ts); these functions only read rows.
6export type SnapshotRow = {
7 ref: string;
8 sessionId: string | null;
9 claimSessionId: string | null;
10 agentId: string | null;
11 agentName: string | null;
12 executor: string | null;
13 worktree: string | null;
14 sharedTree: boolean;
15 readonly: boolean;
16 state: string;
17 deniedTools: string[];
18};
19
20export type Snapshot = { at: string; routed: boolean; dispatches: SnapshotRow[] };
21
22const LIVE_STATES = new Set(['prepared', 'launched', 'bound', 'claimed']);
23
24// A subagent is known by its agent id; a teammate in its own pane is the main loop of its own session,
25// so it is known by the session that claimed the ticket. The orchestrator's own session id sits on the
26// rows it dispatched as sessionId, never claimSessionId, so it is not mistaken for its executors.
27export function callerRow(rows: readonly SnapshotRow[], agentId: string | undefined, sessionId: string): SnapshotRow | null {
28 const live = rows.filter((row) => LIVE_STATES.has(row.state));
29 if (agentId) return live.find((row) => row.agentId === agentId) ?? null;
30 return live.find((row) => row.state === 'claimed' && row.claimSessionId === sessionId) ?? null;
31}
32
33export function deniedToolsDenial(toolName: string, row: SnapshotRow | null): string | null {
34 if (!row) return null;
35 const pattern = deniedToolMatch(toolName, row.deniedTools);
36 if (!pattern) return null;
37 return `sidequest: ${toolName} is denied to executors on ${row.ref} by the board deniedTools setting (${pattern}). Finish the ticket with the remaining tools; when it needs ${toolName}, comment why on the ticket and release it.`;
38}
39
40export const HEREDOC_DENIAL = 'sidequest: the harness refuses heredocs in isolated worktrees; Write the script to your scratchpad and run it by path.';
41
42function skipQuoted(command: string, index: number): number {
43 const quote = command[index];
44 for (let cursor = index + 1; cursor < command.length; cursor += 1) {
45 if (quote === '"' && command[cursor] === '\\') cursor += 1;
46 else if (command[cursor] === quote) return cursor;
47 }
48 return command.length;
49}
50
51function startsComment(command: string, index: number): boolean {
52 const previous = command[index - 1];
53 return previous === undefined || /[\s;&|(]/.test(previous);
54}
55
56export function hasHereDoc(command: string): boolean {
57 for (let index = 0; index < command.length; index += 1) {
58 const character = command[index];
59 if (character === "'" || character === '"') index = skipQuoted(command, index);
60 else if (character === '#' && startsComment(command, index)) {
61 const lineEnd = command.indexOf('\n', index);
62 if (lineEnd < 0) return false;
63 index = lineEnd;
64 } else if (character === '<' && command[index + 1] === '<' && command[index + 2] !== '<') return true;
65 }
66 return false;
67}
68
69export function harnessWorktreeCwd(cwd: string): boolean {
70 const parts = cwd.split(/[\\/]+/).map((part) => part.toLowerCase());
71 return parts.some((part, index) => (
72 part === 'worktrees'
73 && parts.slice(0, index).includes('.claude')
74 && (parts[index + 1]?.startsWith('agent-') === true || parts[index + 2]?.startsWith('agent-') === true)
75 ));
76}
77
78export function heredocDenial(command: string, row: SnapshotRow | null, cwd: string): string | null {
79 if (!hasHereDoc(command)) return null;
80 return row?.sharedTree === false || harnessWorktreeCwd(cwd) ? HEREDOC_DENIAL : null;
81}
82
83export const REPEATED_COMMAND_WARNING = 'sidequest: you have run this exact command 3 times; if you are waiting on something, run it with run_in_background and let the completion notification wake you — polling burns ~14s and ~60k tokens per call';
84
85function warnsAt(count: number, lastWarning: number): boolean {
86 return count >= 3 && (lastWarning === 0 || count - lastWarning >= 5);
87}
88
89export function repeatedCommandStep(previous: RepeatedCommand | undefined, rawCommand: string): { next: RepeatedCommand; warn: boolean } | null {
90 const command = rawCommand.trim().replace(/\s+/g, ' ');
91 if (!command) return null;
92 const { count, lastWarning } = previous?.command === command ? { count: previous.count + 1, lastWarning: previous.lastWarning } : { count: 1, lastWarning: 0 };
93 const warn = warnsAt(count, lastWarning);
94 return { next: { command, count, lastWarning: warn ? count : lastWarning }, warn };
95}
96
97export const TASK_OUTPUT_DENIAL = 'sidequest: native Agent results arrive automatically. Use pulse <ref> / changes --since for liveness. Use TaskStop only after terminal board evidence.';
98
99function sidequestTaskId(value: unknown, dispatchedIds: ReadonlySet<string>): boolean {
100 if (typeof value !== 'string' || !value) return false;
101 const lowered = value.toLowerCase();
102 if (lowered.startsWith('sidequest-') || dispatchedIds.has(value)) return true;
103 return lowered.includes('sidequest') && /@session-[^\s]+/i.test(value);
104}
105
106export function taskOutputDenial(toolInput: { task_id?: unknown; id?: unknown }, rows: readonly SnapshotRow[], sessionId: string): string | null {
107 const dispatchedIds = new Set(rows
108 .filter((row) => sessionId && row.sessionId === sessionId)
109 .flatMap((row) => [row.agentName, row.agentId])
110 .filter((value): value is string => Boolean(value)));
111 return sidequestTaskId(toolInput.task_id, dispatchedIds) || sidequestTaskId(toolInput.id, dispatchedIds) ? TASK_OUTPUT_DENIAL : null;
112}
113
114// A closed row's state is its terminal outcome (the snapshot writer's dispatchRowState).
115export function terminalPeerRow(rows: readonly SnapshotRow[], to: string): SnapshotRow | null {
116 return rows.filter((row) => to && row.agentName === to && !LIVE_STATES.has(row.state)).at(-1) ?? null;
117}
118
119export function terminalPeerDenial(row: SnapshotRow, to: string): string {
120 return `sidequest: ${row.ref} is terminal (${row.state}) and executor "${to}" is closed. `
121 + 'File a follow-up ticket for changes, or redispatch the existing ticket when it was released without a pending submission.';
122}
123
124export function isolatedPeerRow(rows: readonly SnapshotRow[], to: string): SnapshotRow | null {
125 return rows.find((row) => to && row.agentName === to && row.sharedTree === false && row.worktree) ?? null;
126}
127
128export function missingWorktreeDenial(row: SnapshotRow): string {
129 return `sidequest: ${row.ref} is worktree-isolated but its recorded worktree is gone. `
130 + 'Do not resume this executor because it could write to the shared checkout. Redispatch the ticket instead.';
131}
132
133const SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
134const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit']);
135export const SUBSTANTIVE_ADVISORY_THRESHOLD = 25;
136
137function isBoardInteraction(toolName: string, command: string, subagentType: string): boolean {
138 if (toolName.startsWith('mcp__plugin_sidequest_board__')) return true;
139 if (toolName === 'Agent') return /^(?:sidequest:)?sidequest-exec/.test(subagentType);
140 return SHELL_TOOLS.has(toolName) && /(?:^|[\s"'\\/])sidequest(?:\.js)?(?=\s|["']|$)/i.test(command);
141}
142
143function isPureRead(command: string): boolean {
144 const parts = command.split(/(?:&&|\|\||;)/).map((part) => part.trim()).filter(Boolean);
145 return parts.every((part) => /^(?:cd\s+\S+|(?:git\s+)?(?:status|diff|log|show|branch\s+--show-current|rev-parse|ls-files)|(?:ls|dir|pwd|cat|head|tail|rg|grep|find|which|where|get-(?:childitem|content|item|location)|select-string|test-path)\b)/i.test(part));
146}
147
148// Test and build runs check work already done; counting them as edits fired the nudge on verification loops.
149function isVerificationRun(command: string): boolean {
150 const parts = command.split(/(?:&&|\|\||;)/).map((part) => part.trim()).filter(Boolean);
151 return parts.every((part) => /^(?:cd\s+\S+$|npm\s+(?:--prefix\s+\S+\s+)?(?:test|t|run\s+(?:test|build|typecheck)\S*)\b|node\s+(?:--import\s+\S+\s+)*(?:-e|--eval|--test)\b|(?:npx\s+)?tsc\b)/i.test(part));
152}
153
154function isSubstantive(toolName: string, command: string): boolean {
155 if (EDIT_TOOLS.has(toolName)) return true;
156 return SHELL_TOOLS.has(toolName) && !isPureRead(command) && !isVerificationRun(command);
157}
158
159export function inlineWorkNudge(substantiveActions: number): string {
160 return `sidequest: ${substantiveActions} substantive actions without a board call. Consider one owner and a focused check for a bounded fix. Small fixes at known locations may stay inline; use a story or wave only for 3+ independently checkable pieces with a stated reason.`;
161}
162
163// Returns the new state, and whether this call crossed the threshold; the caller checks the board only then.
164export function inlineWorkStep(state: InlineWork, toolName: string, command: string, subagentType: string): { next: InlineWork; crossed: boolean } {
165 if (isBoardInteraction(toolName, command.trim(), subagentType)) return { next: { ...state, boardInteraction: true }, crossed: false };
166 return isSubstantive(toolName, command.trim()) ? countSubstantiveAction(state) : { next: state, crossed: false };
167}
168
169function countSubstantiveAction(state: InlineWork): { next: InlineWork; crossed: boolean } {
170 const substantiveActions = (state.substantiveActions ?? 0) + 1;
171 const crossed = !state.boardInteraction && !state.advisorySurfaced && substantiveActions >= SUBSTANTIVE_ADVISORY_THRESHOLD;
172 return { next: { ...state, substantiveActions, advisorySurfaced: state.advisorySurfaced || crossed }, crossed };
173}
174hooks/guards/git-guards.ts 255 lines1import type { EngineInterface } from 'claude-code'
2
3import type { SnapshotRow } from './context-guards.js'
4import {
5 branchName, destructiveMatch, gitInvocations, invocationDirectory, isTagRef, pushesEverything, samePath, shellTokens, tagAction,
6} from './git-command.js'
7import type { GitInvocation } from './git-command.js'
8import { withoutHereDocBodies } from './heredocs.js'
9import { caller, machine, projectRoot, registeredRoot, sidequestHome, slugify } from './snapshot.js'
10import { resolveAbsolute } from './text-guards.js'
11import type { Machine } from './text-guards.js'
12import type { RepoFacts } from '../../types/index.js'
13
14// git runs only once the command text already names a destructive, publishing or mutating invocation, and
15// the repository behind each directory is asked once per session.
16
17const REPO_ROOTS = { plugin: 'sidequest', key: 'repoRoot' } as const
18const PROCESS_TIMEOUT_MS = 10_000
19export const REPO_FACTS_ARGS = ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir']
20const MUTATING_SUBCOMMANDS = new Set([
21 'add', 'am', 'apply', 'branch', 'checkout', 'cherry-pick', 'clean', 'commit', 'merge', 'mv',
22 'rebase', 'reset', 'restore', 'revert', 'rm', 'sparse-checkout', 'stash', 'switch', 'tag',
23 'update-ref', 'worktree',
24])
25
26type Shell = { command: string; cwd: string; host: Machine }
27type Repo = { root: string; commonDir: string | null }
28type Upstream = { remote: string; branch: string }
29type LockHolder = { sessionId?: unknown; repo?: unknown }
30
31async function git($: EngineInterface, directory: string, args: string[]): Promise<string> {
32 const ran = await $.process.run(['git', ...args], { cwd: directory, timeoutMs: PROCESS_TIMEOUT_MS })
33 return ran.exitCode === 0 ? ran.stdout : ''
34}
35
36// A directory that is not a repository yet is asked again next time, since a later clone or init makes it one.
37async function repoFacts($: EngineInterface, directory: string, host: Machine): Promise<RepoFacts | null> {
38 const { value = {} } = await $.state.get(REPO_ROOTS)
39 const cached = value[directory]
40 if (cached) return cached
41 if (!(await $.fs.exists(directory))) return null
42 const [root, commonDir] = (await git($, directory, REPO_FACTS_ARGS)).split(/\r?\n/)
43 if (!root || !commonDir) return null
44 const facts = { root: resolveAbsolute(root, host), commonDir: resolveAbsolute(commonDir, host) }
45 await $.state.set(REPO_ROOTS, { ...value, [directory]: facts })
46 return facts
47}
48
49async function repoAt($: EngineInterface, shell: Shell, index: number, dashC: string | null): Promise<Repo> {
50 const directory = invocationDirectory(shell.command, index, dashC, shell.cwd, shell.host)
51 const facts = await repoFacts($, directory, shell.host)
52 return facts ?? { root: directory, commonDir: null }
53}
54
55async function shellOf($: EngineInterface, command: string): Promise<Shell> {
56 return { command, cwd: await $.session.cwd(), host: await machine($) }
57}
58
59// Keep the repository path only in the recovery command so long temp paths fit the hook output budget.
60function destructiveRefusal(label: string, repo: string, dirty: string[]): string {
61 const shown = dirty.slice(0, 10).map((line) => ` ${line}`)
62 if (dirty.length > shown.length) shown.push(` … +${dirty.length - shown.length} more`)
63 return [
64 `sidequest: refusing ${label} — the shared checkout (a board-registered project root; your evidence clone is not one) has ${dirty.length} uncommitted change(s) that this operation would destroy.`,
65 ...shown,
66 'Some of this may be a live executor\'s finished work that lost its worktree; the shared tree is not yours alone.',
67 `Next step: preserve every dirty path in a named stash (for example, \`git stash push -u -m "sidequest recovery"\`), then run \`sidequest recover-shared --project "${repo}" --stash <stash@{n}> --yes\`. That exact recovery action verifies the stash before it runs \`git reset --hard && git clean -fd\`.`,
68 ].join('\n')
69}
70
71function publicationRefusal(label: string, repo: string): string {
72 return [
73 `sidequest: refusing ${label} without the current session's publish lock.`,
74 ` repo: ${repo}`,
75 'Acquire it with `sidequest publish lock` before a deliberate release cut, then retry.',
76 'This blocks the entire shell invocation, including earlier compound-command segments.',
77 'This is a local early warning. Server-side GitHub rules remain the guarantee.',
78 ].join('\n')
79}
80
81function sharedTreeCommitRefusal(ref: string): string {
82 return [
83 `sidequest: refusing raw git commit for ${ref} in the shared checkout.`,
84 'Use mcp__plugin_sidequest_board__commit so Sidequest stages and commits only the approved ticket scope.',
85 'If the path needs approval first, request scope before committing.',
86 ].join('\n')
87}
88
89function isolatedMutationRefusal(root: string): string {
90 return `sidequest: refusing a mutating git command against the shared checkout (${root}) from an isolated worktree. The shared checkout means the project's registered root and every other executor's worktree; a clone you created under your ticket's verification directory or scratchpad is not it and stays writable. Read-only git commands such as log, diff, show, rev-parse, and ls-files are allowed for review; make repository changes only in the assigned worktree.`
91}
92
93async function dirtyCheckoutDenial($: EngineInterface, shell: Shell): Promise<string | null> {
94 const destructive = destructiveMatch(shell.command)
95 if (!destructive) return null
96 const { root } = await repoAt($, shell, destructive.index, destructive.dashC)
97 if (!(await registeredRoot($, root, shell.host))) return null
98 const dirty = (await git($, root, ['status', '--porcelain'])).split(/\r?\n/).filter(Boolean)
99 return dirty.length ? destructiveRefusal(destructive.label, root, dirty) : null
100}
101
102async function upstreamOf($: EngineInterface, root: string): Promise<Upstream | null> {
103 const ref = (await git($, root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])).trim()
104 const slash = ref.indexOf('/')
105 return slash > 0 ? { remote: ref.slice(0, slash), branch: ref.slice(slash + 1) } : null
106}
107
108async function defaultBranch($: EngineInterface, root: string, remote: string): Promise<string> {
109 const ref = (await git($, root, ['symbolic-ref', '--quiet', '--short', `refs/remotes/${remote}/HEAD`])).trim()
110 if (ref.startsWith(`${remote}/`)) return ref.slice(remote.length + 1)
111 const branches = (await git($, root, ['for-each-ref', '--format=%(refname:short)', `refs/remotes/${remote}`])).split(/\r?\n/)
112 .map((branch) => branch.trim().replace(`${remote}/`, ''))
113 .filter((branch) => branch && branch !== 'HEAD')
114 return ['main', 'master', 'trunk'].find((branch) => branches.includes(branch)) || branches[0] || 'main'
115}
116
117async function pushRemote($: EngineInterface, root: string, tokens: string[], upstream: Upstream | null): Promise<{ remote: string; specs: string[] }> {
118 const positional = tokens.filter((token) => !token.startsWith('-'))
119 const [first, ...rest] = positional
120 const named = first !== undefined && (await git($, root, ['remote', 'get-url', first])).trim() !== ''
121 return named && first ? { remote: first, specs: rest } : { remote: upstream?.remote || 'origin', specs: positional }
122}
123
124function pushDestination(spec: string): string {
125 return spec.includes(':') ? spec.slice(spec.indexOf(':') + 1) : spec
126}
127
128function specPublishes(spec: string, defaultTarget: string, published: string, integration: string): boolean {
129 const destination = pushDestination(spec)
130 if (!destination || isTagRef(destination)) return true
131 const target = destination === 'HEAD' ? defaultTarget : branchName(destination)
132 return target === published && target !== integration
133}
134
135function pushPublishes(specs: string[], defaultTarget: string, published: string, integration: string): boolean {
136 if (specs.length === 0) return defaultTarget === published && defaultTarget !== integration
137 return specs.some((spec) => specPublishes(spec, defaultTarget, published, integration))
138}
139
140function wholesalePush(tokens: string[], specs: string[]): boolean {
141 return pushesEverything(tokens) || specs.some((spec) => isTagRef(spec) || spec.includes('*'))
142}
143
144// The published branch a push lands on, or null when it lands elsewhere (the integration branch included).
145async function publishedPushBranch($: EngineInterface, root: string, args: string, integration: string): Promise<string | null> {
146 const tokens = shellTokens(args)
147 const upstream = await upstreamOf($, root)
148 const { remote, specs } = await pushRemote($, root, tokens, upstream)
149 const published = await defaultBranch($, root, remote)
150 if (wholesalePush(tokens, specs)) return published
151 const defaultTarget = upstream?.branch || (await git($, root, ['branch', '--show-current'])).trim()
152 return pushPublishes(specs, defaultTarget, published, integration) ? published : null
153}
154
155// Only a registered root has a board config; anything else keeps the 'main' default.
156async function integrationBranch($: EngineInterface, root: string, host: Machine): Promise<string> {
157 if (!(await registeredRoot($, root, host))) return 'main'
158 try {
159 const ran = await $.process.run(['node', `${$.plugin.root}/bin/sidequest.js`, 'board-config', '--json', '--project', root], { timeoutMs: PROCESS_TIMEOUT_MS })
160 const config: { integrationBranch?: unknown } = JSON.parse(ran.stdout)
161 return typeof config.integrationBranch === 'string' ? config.integrationBranch : 'main'
162 } catch {
163 // An unavailable board must not make ordinary Git commands fail closed.
164 return 'main'
165 }
166}
167
168async function lockHolder($: EngineInterface, commonDir: string): Promise<LockHolder | null> {
169 try {
170 const holder: LockHolder = JSON.parse(await $.fs.read(`${commonDir}/sidequest-publish.lock`))
171 return holder
172 } catch {
173 return null
174 }
175}
176
177async function publishLockHeld($: EngineInterface, shell: Shell, repo: Repo): Promise<boolean> {
178 const holder = repo.commonDir ? await lockHolder($, repo.commonDir) : null
179 if (!holder || holder.sessionId !== (await $.session.id())) return false
180 return typeof holder.repo !== 'string' || samePath(holder.repo, repo.root, shell.host)
181}
182
183function publicationVerb(invocation: GitInvocation): boolean {
184 return invocation.subcommand === 'push' || (invocation.subcommand === 'tag' && tagAction(invocation.args))
185}
186
187async function publicationLabel($: EngineInterface, shell: Shell, invocation: GitInvocation, root: string): Promise<string | null> {
188 if (invocation.subcommand === 'tag') return 'manual git tag'
189 const integration = await integrationBranch($, root, shell.host)
190 const published = await publishedPushBranch($, root, invocation.args, integration)
191 return published ? `a push to the published branch (${published})` : null
192}
193
194async function publicationDenial($: EngineInterface, shell: Shell): Promise<string | null> {
195 for (const invocation of gitInvocations(shell.command).filter(publicationVerb)) {
196 const repo = await repoAt($, shell, invocation.index, invocation.dashC)
197 const label = await publicationLabel($, shell, invocation, repo.root)
198 if (label) return (await publishLockHeld($, shell, repo)) ? null : publicationRefusal(label, repo.root)
199 }
200 return null
201}
202
203// Text first: no git process starts unless the command names a destructive or publishing invocation.
204export async function destructiveGitDenial($: EngineInterface, rawCommand: string): Promise<string | null> {
205 const command = withoutHereDocBodies(rawCommand)
206 if (!destructiveMatch(command) && !gitInvocations(command).some(publicationVerb)) return null
207 const shell = await shellOf($, command)
208 return (await publicationDenial($, shell)) ?? (await dirtyCheckoutDenial($, shell))
209}
210
211async function agentWorktree($: EngineInterface, shell: Shell, root: string, project: string): Promise<boolean> {
212 const cut = Math.max(root.lastIndexOf('/'), root.lastIndexOf('\\'))
213 if (!root.slice(cut + 1).startsWith('agent-')) return false
214 const parents = [`${await sidequestHome($, shell.host)}/worktrees/${await slugify(project, shell.host.windows)}`, `${project}/.claude/worktrees`]
215 return parents.some((parent) => samePath(parent, root.slice(0, cut), shell.host))
216}
217
218// The board hands out the registered root and the agent worktrees; a clone an executor made itself under its
219// verification directory or scratchpad belongs to nobody else and stays writable (SQ-3436).
220async function protectedCheckout($: EngineInterface, shell: Shell, row: SnapshotRow, root: string, project: string): Promise<boolean> {
221 if (samePath(root, project, shell.host)) return true
222 if (!row.worktree || samePath(root, row.worktree, shell.host)) return false
223 return agentWorktree($, shell, root, project)
224}
225
226async function isolatedMutationDenial($: EngineInterface, shell: Shell, row: SnapshotRow, root: string, project: string): Promise<string | null> {
227 return (await protectedCheckout($, shell, row, root, project)) ? isolatedMutationRefusal(root) : null
228}
229
230function sharedTreeCommitDenial(row: SnapshotRow, subcommand: string, sharedCheckout: boolean): string | null {
231 return row.state === 'claimed' && subcommand === 'commit' && sharedCheckout ? sharedTreeCommitRefusal(row.ref) : null
232}
233
234async function mutationDenial($: EngineInterface, shell: Shell, row: SnapshotRow, project: string, invocation: GitInvocation): Promise<string | null> {
235 const { root } = await repoAt($, shell, invocation.index, invocation.dashC)
236 if (!row.sharedTree) return isolatedMutationDenial($, shell, row, root, project)
237 return sharedTreeCommitDenial(row, invocation.subcommand, samePath(root, project, shell.host))
238}
239
240// An isolated executor may not mutate a checkout the board handed to anyone else, and a shared-tree claim
241// commits only through the board, which stages its approved scope alone.
242export async function sharedCheckoutGitDenial($: EngineInterface, command: string, agentId: string | undefined): Promise<string | null> {
243 const mutations = gitInvocations(command).filter((invocation) => MUTATING_SUBCOMMANDS.has(invocation.subcommand))
244 if (!mutations.length) return null
245 const row = await caller($, agentId)
246 if (!row) return null
247 const shell = await shellOf($, command)
248 const project = await projectRoot($, shell.cwd, shell.host)
249 for (const invocation of mutations) {
250 const denial = await mutationDenial($, shell, row, project, invocation)
251 if (denial) return denial
252 }
253 return null
254}
255hooks/guards/executor-admission.ts 178 lines1import type { EngineInterface, On, ToolCallResult } from 'claude-code'
2
3import { classify, isReadOnlyExecutor } from '../../src/lib/exec-names.js'
4import { mainThreadMutationDenial } from '../../src/hooks/shared/main-thread-mutations.js'
5import { namesShellWrite } from '../../src/hooks/shared/shell-writes.js'
6import { callerRow } from './context-guards.js'
7import type { Snapshot, SnapshotRow } from './context-guards.js'
8import { projectDirectory, readSnapshot } from './snapshot.js'
9
10// The executor admission decisions (hooks/force-exec-bypass.js) need the store, agentsync and git, which a
11// module cannot load. So the module answers every call the snapshot proves that code would pass, and runs it
12// as one process only for an Agent spawn or a call it could refuse. The snapshot holds one project's rows
13// while the decision reads every project's live store, so a missing row proves nothing.
14
15export const ADMISSION_FAILED = 'sidequest: executor admission guard could not run; spawn refused'
16
17export const GUARDED_TOOLS = /^(Bash|PowerShell|Edit|Write|MultiEdit|NotebookEdit|SendMessage|mcp__plugin_sidequest_board__(dispatch|update|scopeRequest|remove|verdict))$/
18const SHELL_TOOLS = /^(Bash|PowerShell)$/
19const WRITE_TOOLS = /^(Edit|Write|MultiEdit|NotebookEdit)$/
20const CLI_DISPATCH = /\bsidequest(?:\.js)?["']?\s+dispatch\s+SQ-\d+\b/i
21const CURRENT_EXECUTOR_KINDS = new Set([
22 'claude_builtin', 'codex_dispatch', 'discovered_model', 'read_only_claude_builtin', 'read_only_codex_dispatch', 'read_only_discovered_model',
23])
24const RESERVED_KEYS = new Set(['tool', 'tool_use_id', 'agentId', 'requestMeta', 'consent'])
25const SNAPSHOT_TTL_MS = 2000
26// The store rewrites the snapshot on every dispatch change, but a failed side write leaves an old one in place.
27const SNAPSHOT_STALE_MS = 60_000
28
29// row is the caller's own live dispatch: a subagent's by agent id, a teammate's by the session that claimed it.
30export type Caller = { agentId: string; type: string; sessionId: string; row: SnapshotRow | null; fresh: boolean }
31export type Decision = { deny?: string; updatedInput?: Record<string, unknown>; context?: string }
32
33function isCurrentExecutor(type: string): boolean {
34 return CURRENT_EXECUTOR_KINDS.has(classify(type).kind)
35}
36
37// Only a fresh row binding this agent to a live dispatch proves the decision would not call it finished.
38export function finishedExecutorUnproven(caller: Caller): boolean {
39 if (!caller.agentId || !isCurrentExecutor(caller.type)) return false
40 return !(caller.fresh && caller.row?.sessionId === caller.sessionId)
41}
42
43// The decision's dispatch and finished-executor checks need an agent id; its read-only check needs only the type.
44export function shellNeedsDecision(caller: Caller, command: string): boolean {
45 if (isReadOnlyExecutor(caller.type) && namesShellWrite(command)) return true
46 return Boolean(caller.agentId) && (CLI_DISPATCH.test(command) || finishedExecutorUnproven(caller))
47}
48
49// A helper's write is checked against its owner's scope, which can sit in any project's live store.
50export function writeNeedsDecision(caller: Caller): boolean {
51 if (!caller.agentId) return false
52 return isCurrentExecutor(caller.type) ? finishedExecutorUnproven(caller) : true
53}
54
55function textField(e: object, name: string): string {
56 const value: unknown = Reflect.get(e, name)
57 return typeof value === 'string' ? value : ''
58}
59
60// A message always runs the decision: its late-steer check finds a terminal recipient in any project's store.
61export function needsDecision(tool: string, e: object, caller: Caller): boolean {
62 if (SHELL_TOOLS.test(tool)) return shellNeedsDecision(caller, textField(e, 'command'))
63 if (WRITE_TOOLS.test(tool)) return writeNeedsDecision(caller)
64 return tool === 'SendMessage' || Boolean(caller.agentId)
65}
66
67type OutputFields = { permissionDecision?: string; permissionDecisionReason?: string; updatedInput?: Record<string, unknown>; additionalContext?: string }
68
69// The decision process prints the PreToolUse command-hook output it always printed, or nothing to pass.
70export function parseDecision(stdout: string): Decision {
71 if (!stdout.trim()) return {}
72 const output: OutputFields = (JSON.parse(stdout) as { hookSpecificOutput?: OutputFields }).hookSpecificOutput ?? {}
73 if (output.permissionDecision === 'deny') return { deny: String(output.permissionDecisionReason ?? '') }
74 return { updatedInput: output.updatedInput, context: output.additionalContext }
75}
76
77// The rewrite replaces the arguments whole: the decision removes fields (isolation, model, mode) as well as setting them.
78export function rewrittenCall<E extends object>(e: E, updatedInput: Record<string, unknown>): E {
79 const rewritten = { ...e }
80 for (const key of Object.keys(rewritten)) {
81 if (!RESERVED_KEYS.has(key)) Reflect.deleteProperty(rewritten, key)
82 }
83 return Object.assign(rewritten, updatedInput)
84}
85
86const snapshots = new Map<string, { readAt: number; snapshot: Snapshot | null }>()
87
88async function cachedSnapshot($: EngineInterface, directory: string): Promise<Snapshot | null> {
89 const cached = snapshots.get(directory)
90 if (cached && Date.now() - cached.readAt < SNAPSHOT_TTL_MS) return cached.snapshot
91 const snapshot = await readSnapshot($, directory).catch(() => null)
92 snapshots.set(directory, { readAt: Date.now(), snapshot })
93 return snapshot
94}
95
96function isFresh(snapshot: Snapshot | null): boolean {
97 return snapshot !== null && Date.now() - Date.parse(snapshot.at) < SNAPSHOT_STALE_MS
98}
99
100async function agentType($: EngineInterface, agentId: string): Promise<string> {
101 return (await $.agent.list()).find((agent) => agent.id === agentId)?.type ?? ''
102}
103
104// A subagent's type is the runtime's; a teammate runs as its session's main loop, so its claimed row names it.
105async function callerOf($: EngineInterface, agentId: string): Promise<Caller> {
106 const sessionId = await $.session.id()
107 const snapshot = await cachedSnapshot($, await projectDirectory($, await $.session.cwd()))
108 const row = callerRow(snapshot?.dispatches ?? [], agentId || undefined, sessionId)
109 const type = agentId ? await agentType($, agentId) : row?.executor ?? ''
110 return { agentId, sessionId, row, fresh: isFresh(snapshot), type }
111}
112
113function toolArguments(e: object): Record<string, unknown> {
114 return Object.fromEntries(Object.entries(e).filter(([key]) => !RESERVED_KEYS.has(key)))
115}
116
117let transcriptPath = ''
118
119async function runDecision($: EngineInterface, tool: string, e: object, caller: Caller): Promise<Decision> {
120 const input = {
121 hook_event_name: 'PreToolUse', tool_name: tool, tool_input: toolArguments(e), session_id: caller.sessionId,
122 cwd: await $.session.cwd(), transcript_path: transcriptPath || undefined, agent_id: caller.agentId || undefined, agent_type: caller.type || undefined,
123 }
124 const ran = await $.process.run(['node', `${$.plugin.root}/hooks/force-exec-bypass.js`], { stdin: JSON.stringify(input), timeoutMs: 10_000 })
125 if (ran.exitCode !== 0) throw new Error(`force-exec-bypass exited ${ran.exitCode}: ${ran.stderr.slice(0, 200)}`)
126 return parseDecision(ran.stdout)
127}
128
129function withContext(ran: ToolCallResult, context: string | undefined): ToolCallResult {
130 if (!context || ran.deny !== undefined) return ran
131 return { ...ran, context: [...(ran.context ?? []), context] }
132}
133
134async function applied<E extends object>(e: E, next: (input: E) => Promise<ToolCallResult>, decision: Decision): Promise<ToolCallResult> {
135 if (decision.deny !== undefined) return { deny: decision.deny }
136 return withContext(await next(decision.updatedInput ? rewrittenCall(e, decision.updatedInput) : e), decision.context)
137}
138
139// A claimed teammate is no main thread either, though it calls without an agent id.
140async function callerDecision($: EngineInterface, e: { tool: string }, caller: Caller): Promise<Decision> {
141 const denial = caller.row ? mainThreadMutationDenial(e.tool, toolArguments(e)) : null
142 if (denial) return { deny: denial }
143 return needsDecision(e.tool, e, caller) ? runDecision($, e.tool, e, caller) : {}
144}
145
146// The main-thread-only fields are refused from the call alone, so no engine failure can admit them.
147async function guardedDecision($: EngineInterface, e: { tool: string; agentId?: string }): Promise<Decision> {
148 const denial = e.agentId ? mainThreadMutationDenial(e.tool, toolArguments(e)) : null
149 if (denial) return { deny: denial }
150 try {
151 return await callerDecision($, e, await callerOf($, e.agentId ?? ''))
152 } catch {
153 $.ui.status('sidequest: executor guard skipped')
154 return {}
155 }
156}
157
158// A spawn admitted by accident skips routing and bypass-mode stamping, so this one fails closed.
159async function admissionDecision($: EngineInterface, e: { tool: string; agentId?: string }): Promise<Decision> {
160 try {
161 return await runDecision($, e.tool, e, await callerOf($, e.agentId ?? ''))
162 } catch {
163 return { deny: ADMISSION_FAILED }
164 }
165}
166
167export function registerExecutorAdmission(on: On): void {
168 // tool.call carries no transcript_path; the helper evidence rule names the session transcript from here.
169 on('classic.UserPromptSubmit', ($, e, next) => {
170 transcriptPath = e.transcript_path
171 return next(e)
172 })
173
174 on('tool.call', { tool: 'Agent' }, async ($, e, next) => applied(e, next, await admissionDecision($, e)))
175
176 on('tool.call', { tool: GUARDED_TOOLS }, async ($, e, next) => applied(e, next, await guardedDecision($, e)))
177}
178hooks/guards/snapshot.ts 114 lines1import type { EngineInterface } from 'claude-code'
2
3import { callerRow } from './context-guards.js'
4import type { Snapshot, SnapshotRow } from './context-guards.js'
5import { resolveAbsolute } from './text-guards.js'
6import type { Machine } from './text-guards.js'
7
8// The module mirrors src/lib/store/paths.ts (homeRoot, nearestRepoRoot, slugify) to find the snapshot the
9// store writes, since it cannot load the store. A slug it gets wrong reads no rows and every guard stays silent.
10
11export async function machine($: EngineInterface): Promise<Machine> {
12 const windows = (await $.env.get('OS')) === 'Windows_NT'
13 const home = (windows ? await $.env.get('USERPROFILE') : undefined) ?? (await $.env.get('HOME')) ?? ''
14 return { home, windows }
15}
16
17async function readOrNull($: EngineInterface, file: string): Promise<string | null> {
18 try {
19 return await $.fs.read(file)
20 } catch {
21 return null
22 }
23}
24
25// A linked worktree always holds a .git file, and $.fs can only read files.
26export async function worktreeExists($: EngineInterface, worktree: string): Promise<boolean> {
27 return (await readOrNull($, `${worktree}/.git`)) !== null
28}
29
30function parentOf(directory: string): string {
31 const cut = Math.max(directory.lastIndexOf('/'), directory.lastIndexOf('\\'))
32 return cut > directory.search(/[\\/]/) ? directory.slice(0, cut) : directory.slice(0, cut + 1)
33}
34
35const LINKED_WORKTREE = /^gitdir:\s*(.+?)[\\/]\.git[\\/]worktrees[\\/][^\\/]+\s*$/
36
37// A linked worktree's .git file names its main checkout, which is the board's project root.
38async function gitRootAt($: EngineInterface, directory: string): Promise<string | null> {
39 const gitFile = await readOrNull($, `${directory}/.git`)
40 if (gitFile !== null) return LINKED_WORKTREE.exec(gitFile.trim())?.[1] ?? directory
41 return (await readOrNull($, `${directory}/.git/HEAD`)) !== null ? directory : null
42}
43
44async function repoRoot($: EngineInterface, cwd: string): Promise<string> {
45 const marker = cwd.search(/[\\/]\.claude[\\/]worktrees[\\/]/)
46 if (marker >= 0) return cwd.slice(0, marker)
47 for (let directory = cwd; ; directory = parentOf(directory)) {
48 const root = await gitRootAt($, directory)
49 if (root !== null) return root
50 if (parentOf(directory) === directory) return cwd
51 }
52}
53
54export async function slugify(root: string, windows: boolean): Promise<string> {
55 const base = (root.split(/[\\/]/).filter(Boolean).at(-1) ?? '')
56 .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'project'
57 const digest = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(windows ? root.toLowerCase() : root))
58 const hash = [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('').slice(0, 8)
59 return `${base}-${hash}`
60}
61
62export async function sidequestHome($: EngineInterface, host: Machine): Promise<string> {
63 const configured = (await $.env.get('SIDEQUEST_HOME'))?.trim()
64 if (configured) return resolveAbsolute(configured, host)
65 return resolveAbsolute(`${host.home}/.claude/sidequest`, host)
66}
67
68const projectDirectories = new Map<string, Promise<string>>()
69
70// The registered root a cwd belongs to: a linked worktree's main checkout, else the enclosing repo.
71export async function projectRoot($: EngineInterface, cwd: string, host: Machine): Promise<string> {
72 return resolveAbsolute(await repoRoot($, cwd), host)
73}
74
75async function locateProjectDirectory($: EngineInterface, cwd: string): Promise<string> {
76 const host = await machine($)
77 return `${await sidequestHome($, host)}/projects/${await slugify(await projectRoot($, cwd, host), host.windows)}`
78}
79
80// The store creates projects/<slug>/tickets when it registers a root, and the slug hashes that exact path,
81// so only the registered root itself has one, never a clone or a linked worktree of it.
82export async function registeredRoot($: EngineInterface, root: string, host: Machine): Promise<boolean> {
83 return $.fs.exists(`${await sidequestHome($, host)}/projects/${await slugify(root, host.windows)}/tickets`)
84}
85
86// The board folder of the session's project; cached per cwd because a session rarely moves.
87export function projectDirectory($: EngineInterface, cwd: string): Promise<string> {
88 let directory = projectDirectories.get(cwd)
89 if (!directory) {
90 directory = locateProjectDirectory($, cwd)
91 projectDirectories.set(cwd, directory)
92 }
93 return directory
94}
95
96export async function boardExists($: EngineInterface, directory: string): Promise<boolean> {
97 return (await readOrNull($, `${directory}/meta.json`)) !== null
98}
99
100export async function readSnapshot($: EngineInterface, directory: string): Promise<Snapshot | null> {
101 const text = await readOrNull($, `${directory}/dispatch-snapshot.json`)
102 if (text === null) return null
103 const parsed = JSON.parse(text) as { v?: number } & Snapshot
104 return parsed.v === 1 && Array.isArray(parsed.dispatches) ? parsed : null
105}
106
107export async function sessionRows($: EngineInterface): Promise<SnapshotRow[]> {
108 return (await readSnapshot($, await projectDirectory($, await $.session.cwd())))?.dispatches ?? []
109}
110
111export async function caller($: EngineInterface, agentId: string | undefined): Promise<SnapshotRow | null> {
112 return callerRow(await sessionRows($), agentId, await $.session.id())
113}
114hooks/guards/text-guards.ts 193 lines1import { hereDocAt, skipHereDocBodies, type HereDoc } from './heredocs.js';
2
3// Pure decisions for the shell guards: no Node, so the hooks module and the node tests share them.
4export type Machine = { home: string; windows: boolean };
5
6function deleteArguments(command: string): string[] {
7 const commands = /(?:^|[;&|{}()\n])\s*(?:[\w.-]+\s+)*(?:remove-item|rm|rmdir|rd|ri|del|erase)\b([^;&|{}\n]*)/gi;
8 return [...command.matchAll(commands)].map((match) => match[1] || '');
9}
10
11function normalizePath(value: string): string {
12 return value.toLowerCase().replace(/[\\/]+$/, '');
13}
14
15const LEADING_HOME_REFERENCE = /^(?:~|\$home|\$env:userprofile|%userprofile%)(?=[\\/]|$)/i;
16const HOME_REFERENCE = /\$home\b|\$env:userprofile\b|%userprofile%|(?<!\w)~(?=[\\/\s]|$)/i;
17const DRIVE = /^[A-Za-z]:/;
18
19function isAbsolute(target: string, machine: Machine): boolean {
20 return machine.windows ? /^(?:[A-Za-z]:)?[\\/]/.test(target) : target.startsWith('/');
21}
22
23function resolvedSegments(target: string, separator: string): string {
24 const parts: string[] = [];
25 for (const part of target.split(separator)) {
26 if (part === '..') parts.pop();
27 else if (part && part !== '.') parts.push(part);
28 }
29 return parts.join(separator);
30}
31
32// path.resolve for an absolute target, without Node: a drive-less Windows target lands on the home drive.
33export function resolveAbsolute(target: string, machine: Machine): string {
34 if (!machine.windows) return `/${resolvedSegments(target, '/')}`;
35 const unified = target.replace(/\//g, '\\');
36 const drive = DRIVE.exec(unified)?.[0] ?? machine.home.slice(0, 2);
37 return `${drive}\\${resolvedSegments(unified.replace(DRIVE, ''), '\\')}`;
38}
39
40function parentOf(resolved: string, separator: string): string {
41 const cut = resolved.lastIndexOf(separator);
42 return cut <= resolved.indexOf(separator) ? resolved.slice(0, cut + 1) : resolved.slice(0, cut);
43}
44
45function isProtectedPath(argumentsAfterDelete: string, machine: Machine): boolean {
46 const targets = argumentsAfterDelete.replace(/["']/g, '').split(/\s+/).map((target) => target.replace(LEADING_HOME_REFERENCE, () => machine.home));
47 // A home reference anywhere but the start of a target cannot be resolved here, so it still blocks.
48 if (HOME_REFERENCE.test(targets.join(' '))) return true;
49 const separator = machine.windows ? '\\' : '/';
50 const home = resolveAbsolute(machine.home, machine);
51 const root = resolveAbsolute(separator, { ...machine, home });
52 const protectedRoots = [home, `${home}${separator}.claude`, parentOf(home, separator), root].map(normalizePath);
53 return targets
54 .filter((target) => target !== '\\' && isAbsolute(target, machine))
55 .some((target) => {
56 const resolved = resolveAbsolute(target, { ...machine, home });
57 // Any drive root, not only the home drive's: resolveAbsolute ends in the separator only for a root.
58 if (resolved.endsWith(separator)) return true;
59 const normalized = normalizePath(resolved);
60 return protectedRoots.some((protectedRoot) => protectedRoot === normalized || protectedRoot.startsWith(`${normalized}${separator}`));
61 });
62}
63
64export function homeDeleteDenial(command: string, machine: Machine): string | null {
65 const recursive = /^(?:--recursive|-[a-z]*r[a-z]*|-recurse|\/s)$/i;
66 const blocked = deleteArguments(command).some((argumentsAfterDelete) => (
67 argumentsAfterDelete.replace(/["']/g, '').split(/\s+/).some((argument) => recursive.test(argument))
68 && isProtectedPath(argumentsAfterDelete, machine)
69 ));
70 return blocked ? 'sidequest: blocked a recursive delete aimed at the user profile or .claude root. Use a specific project or scratchpad path instead.' : null;
71}
72
73const COMMAND_SHIM = /\bstart-process\b[^\r\n;|]*\s-filepath\s+(?:"|')?(npm|npx|yarn|pnpm)(?:"|')?(?=\s|$)/i;
74
75export function powershellShimContext(command: string): string | null {
76 const shim = command.match(COMMAND_SHIM)?.[1]?.toLowerCase();
77 if (!shim) return null;
78 return `sidequest: ${shim} is a .cmd shim on Windows, so Start-Process needs the shim explicitly. Use \`Start-Process -FilePath "${shim}.cmd" -ArgumentList "run","dev"\` or \`Start-Process -FilePath "cmd" -ArgumentList "/c","${shim}","run","dev"\`.`;
79}
80
81type Quote = 'single' | 'double' | null;
82type Finding = { token: string; quoted: boolean };
83
84const WINDOWS_PATH = /^[A-Za-z]:\\[^\\\s"'`|&;(){}<>]+\\[^\s"'`|&;(){}<>]*/;
85const CONTAINER_PATH_FLAG = /(?:^|[;&|(]\s*)(?:docker\s+(?:exec|run)|kubectl\s+exec)\b[^\r\n;&|]*?(?:\s(?:-w|-v)\s+|\s--(?:workdir|volume|cwd)(?:=|\s+))(?:"|')?\/(?!\/)[^\s"'`|&;(){}<>]*/im;
86
87function escapedNewline(command: string, index: number): boolean {
88 let backslashes = 0;
89 for (let cursor = index - 1; command[cursor] === '\\'; cursor -= 1) backslashes += 1;
90 return backslashes % 2 === 1;
91}
92
93function commentStart(command: string, index: number): boolean {
94 if (command[index] !== '#') return false;
95 const previous = command[index - 1];
96 return previous === undefined || /[\s;&|(]/.test(previous);
97}
98
99// Double quotes keep a backslash literal except before $, `, ", \ or a newline,
100// so only those shapes actually reach the shell as a different path.
101function mangledInsideDoubleQuotes(command: string, token: string, index: number): boolean {
102 if (/\\\$/.test(token)) return true;
103 return token.endsWith('\\') && command[index + token.length] === '\n';
104}
105
106function windowsPath(command: string): Finding | null {
107 let quote: Quote = null;
108 let hereDocs: HereDoc[] = [];
109 const substitutions: Quote[] = [];
110
111 for (let index = 0; index < command.length; index += 1) {
112 const character = command[index];
113 if (quote === 'single') {
114 if (character === "'") quote = null;
115 continue;
116 }
117
118 if (character === '\n' && hereDocs.length > 0 && !escapedNewline(command, index)) {
119 index = skipHereDocBodies(command, index + 1, hereDocs) - 1;
120 hereDocs = [];
121 continue;
122 }
123
124 // A command substitution reopens command context, heredocs and all, even
125 // when it sits inside a double-quoted string.
126 if (character === '$' && command[index + 1] === '(') {
127 substitutions.push(quote);
128 quote = null;
129 index += 1;
130 continue;
131 }
132 if (character === ')' && substitutions.length > 0) {
133 quote = substitutions.pop() ?? null;
134 continue;
135 }
136
137 if (quote === 'double') {
138 const token = command.slice(index).match(WINDOWS_PATH)?.[0];
139 if (token && mangledInsideDoubleQuotes(command, token, index)) return { token, quoted: true };
140 if (character === '\\') {
141 index += 1;
142 } else if (character === '"') {
143 quote = null;
144 }
145 continue;
146 }
147
148 if (commentStart(command, index)) {
149 const lineEnd = command.indexOf('\n', index);
150 if (lineEnd < 0) break;
151 index = lineEnd - 1;
152 continue;
153 }
154
155 if (character === "'") {
156 quote = 'single';
157 continue;
158 }
159 if (character === '"') {
160 quote = 'double';
161 continue;
162 }
163 if (character === '<' && command[index - 1] !== '<' && command[index + 1] === '<') {
164 const parsed = hereDocAt(command, index);
165 if (parsed) {
166 hereDocs.push(parsed.hereDoc);
167 index = parsed.end - 1;
168 continue;
169 }
170 }
171
172 const token = command.slice(index).match(WINDOWS_PATH)?.[0];
173 if (token) return { token, quoted: false };
174 }
175
176 return null;
177}
178
179export type GuardVerdict = { deny: string } | { context: string } | null;
180
181export function bashWindowsPathVerdict(command: string): GuardVerdict {
182 if (CONTAINER_PATH_FLAG.test(command)) {
183 return { context: 'sidequest: Git Bash rewrites this POSIX container path before Docker or kubectl sees it. Use `MSYS_NO_PATHCONV=1 docker exec -w /app contractify-docai ...` or spell the container path as `docker exec -w //app contractify-docai ...`.' };
184 }
185 const found = windowsPath(command);
186 if (!found) return null;
187 return {
188 deny: found.quoted
189 ? `sidequest: double quotes eat the backslash in this Windows path (${found.token}) - a backslash before $ or a line break is not literal there; single-quote it or write it with forward slashes (C:/Users/...).`
190 : `sidequest: unquoted Windows path in a POSIX shell (${found.token}) - backslashes are eaten and the path collapses into a literal filename in cwd; quote the path or write it with forward slashes (C:/Users/...).`,
191 };
192}
193src/lib/denied-tools.ts 27 lines1// Dependency-free: the guard-denied-tools hook bundles it, and board config and category
2// validation share it so a pattern the board accepts is the pattern the hook matches.
3const BOARD_TOOL_PREFIX = 'mcp__plugin_sidequest_board';
4
5function deniedToolPattern(entry: unknown, label: string): string {
6 const pattern = String(entry ?? '').trim();
7 if (!/^[A-Za-z][\w.:-]*$/.test(pattern)) throw new Error(`${label} entries must be tool names or MCP prefixes: ${String(entry)}`);
8 if (pattern.startsWith(BOARD_TOOL_PREFIX)) throw new Error(`${label} cannot deny the Sidequest board tools executors need to claim and close: ${pattern}`);
9 return pattern;
10}
11
12export function normalizeDeniedTools(value: unknown, label = 'deniedTools'): string[] {
13 if (value == null) return [];
14 if (!Array.isArray(value)) throw new Error(`${label} must be an array of tool names.`);
15 return [...new Set(value.map((entry) => deniedToolPattern(entry, label)))];
16}
17
18// A bare MCP server prefix such as mcp__claude-in-chrome denies every tool on that server, the same
19// way the executor frontmatter's disallowedTools reads it.
20function toolDeniedBy(toolName: string, pattern: string): boolean {
21 return toolName === pattern || toolName.startsWith(`${pattern}__`);
22}
23
24export function deniedToolMatch(toolName: string, patterns: readonly string[]): string | null {
25 return patterns.find((pattern) => toolDeniedBy(toolName, pattern)) ?? null;
26}
27hooks/guards/git-command.ts 101 lines1import { resolveAbsolute, type Machine } from './text-guards.js';
2
3// The one shell-command reader every git guard shares: where each git invocation sits, which directory it
4// acts in, and which subcommand it runs. No Node, so the hooks module and its tests share it.
5
6const WORD = String.raw`(?:"[^"]+"|'[^']+'|\S+)`;
7const GIT = String.raw`git\s+(?:-C\s+${WORD}\s+)?`;
8const DESTRUCTIVE: Array<{ pattern: RegExp; label: string }> = [
9 { pattern: new RegExp(`${GIT}reset\\s+[^\\n;|&]*--hard`, 'i'), label: 'git reset --hard' },
10 { pattern: new RegExp(`${GIT}clean\\s+-[a-z]*f`, 'i'), label: 'git clean -f' },
11 { pattern: new RegExp(`${GIT}(?:checkout|restore)\\s+(?:[^\\n;|&]*\\s)?(?:--\\s+)?(?:\\.|:/)(?:\\s|$|;|&|\\|)`, 'i'), label: 'a whole-tree checkout/restore' },
12 { pattern: new RegExp(`${GIT}(?:checkout|switch)\\s+[^\\n;|&]*(?:--force|\\s-f)\\b`, 'i'), label: 'a forced checkout/switch' },
13];
14const INVOCATION = new RegExp(String.raw`(?:^\s*|[;&|\n]\s*|\$\(\s*)(?:&\s*)?git\s+((?:(?:-C|-c|--config-env)\s+${WORD}\s+)*)([a-z][a-z-]*)\b([^\n;|&]*)`, 'gi');
15// Case-sensitive: `-c name=value` sets config, only `-C` names a directory.
16const DASH_C = new RegExp(String.raw`(?:^|\s)-C\s+(${WORD})`, 'g');
17const CD = new RegExp(String.raw`(?:^|[\n;&|])\s*cd\s+(${WORD})`, 'gi');
18
19export type GitInvocation = { index: number; dashC: string | null; subcommand: string; args: string };
20export type DestructiveMatch = { label: string; index: number; dashC: string | null };
21
22export function unquote(value: string): string {
23 return value.replace(/^["']|["']$/g, '');
24}
25
26export function shellTokens(value: string): string[] {
27 return (value.match(/"[^"]*"|'[^']*'|\S+/g) || []).map(unquote);
28}
29
30const BALANCED_QUOTES = /^(?:[^"']|"[^"]*"|'[^']*')*$/;
31
32// A backslash escapes the next character inside quotes or out, so escaped pairs drop before the quotes pair up.
33export function quotedAt(command: string, index: number): boolean {
34 return !BALANCED_QUOTES.test(command.slice(0, index).replace(/\\[\s\S]/g, ''));
35}
36
37function lastDashC(text: string): string | null {
38 const found = [...text.matchAll(DASH_C)].at(-1)?.[1];
39 return found ? unquote(found) : null;
40}
41
42export function gitInvocations(command: string): GitInvocation[] {
43 return [...command.matchAll(INVOCATION)]
44 .filter((match) => !quotedAt(command, match.index))
45 .map((match) => ({ index: match.index, dashC: lastDashC(match[1] ?? ''), subcommand: (match[2] ?? '').toLowerCase(), args: match[3] ?? '' }));
46}
47
48export function destructiveMatch(command: string): DestructiveMatch | null {
49 for (const { pattern, label } of DESTRUCTIVE) {
50 const match = pattern.exec(command);
51 if (match) return { label, index: match.index, dashC: lastDashC(match[0]) };
52 }
53 return null;
54}
55
56// Git Bash spells C:\x as /c/x; only Windows needs the drive put back.
57function gitBashPath(value: string, machine: Machine): string {
58 const drive = machine.windows ? /^\/([a-zA-Z])(?=\/|$)/.exec(value) : null;
59 return drive ? `${drive[1]}:${value.slice(2)}` : value;
60}
61
62export function shellPath(cwd: string, value: string, machine: Machine): string {
63 const target = gitBashPath(value, machine);
64 const absolute = machine.windows ? /^(?:[A-Za-z]:)?[\\/]/.test(target) : target.startsWith('/');
65 return resolveAbsolute(absolute ? target : `${gitBashPath(cwd, machine)}/${target}`, machine);
66}
67
68// Where a git invocation at `index` acts: its own -C, taken from the last `cd` before it, else from cwd.
69export function invocationDirectory(command: string, index: number, dashC: string | null, cwd: string, machine: Machine): string {
70 const cd = [...command.slice(0, index).matchAll(CD)].at(-1)?.[1];
71 const base = shellPath(cwd, cd ? unquote(cd) : '.', machine);
72 return dashC ? shellPath(base, dashC, machine) : base;
73}
74
75export function samePath(left: string, right: string, machine: Machine): boolean {
76 const normalize = (value: string) => {
77 const resolved = resolveAbsolute(gitBashPath(value, machine), machine);
78 return machine.windows ? resolved.toLowerCase() : resolved;
79 };
80 return normalize(left) === normalize(right);
81}
82
83export function tagAction(args: string): boolean {
84 const tokens = shellTokens(args);
85 if (tokens.some((token) => /^(?:-a|--annotate|-s|--sign|-u|--local-user|-d|--delete|-f|--force)$/.test(token))) return true;
86 const lists = tokens.some((token) => /^(?:-l|--list|-n\d*|--contains|--no-contains|--points-at|--merged|--no-merged|--column|--sort|--format)$/.test(token));
87 return !lists && tokens.some((token) => !token.startsWith('-'));
88}
89
90export function isTagRef(value: string): boolean {
91 return value.replace(/^\+/, '').startsWith('refs/tags/') || value === '--tags' || value === '--follow-tags';
92}
93
94export function branchName(value: string): string {
95 return value.replace(/^\+/, '').replace(/^refs\/heads\//, '').replace(/^\/+/, '');
96}
97
98export function pushesEverything(tokens: readonly string[]): boolean {
99 return tokens.some((token) => token === '--tags' || token === '--follow-tags' || token === '--all' || token === '--mirror');
100}
101hooks/guards/heredocs.ts 91 lines1export type HereDoc = {
2 delimiter: string;
3 stripTabs: boolean;
4};
5
6export function hereDocAt(command: string, index: number): { hereDoc: HereDoc; end: number } | null {
7 let cursor = index + 2;
8 const stripTabs = command[cursor] === '-';
9 if (stripTabs) cursor += 1;
10 while (command[cursor] === ' ' || command[cursor] === '\t') cursor += 1;
11
12 const quote = command[cursor];
13 if (quote === "'" || quote === '"') {
14 const end = command.indexOf(quote, cursor + 1);
15 if (end < 0) return null;
16 return { hereDoc: { delimiter: command.slice(cursor + 1, end), stripTabs }, end: end + 1 };
17 }
18
19 const match = command.slice(cursor).match(/^[^\s|&;()<>]+/);
20 if (!match) return null;
21 return { hereDoc: { delimiter: match[0], stripTabs }, end: cursor + match[0].length };
22}
23
24export function skipHereDocBodies(command: string, index: number, hereDocs: HereDoc[]): number {
25 let cursor = index;
26 for (const { delimiter, stripTabs } of hereDocs) {
27 while (cursor < command.length) {
28 const lineEnd = command.indexOf('\n', cursor);
29 const end = lineEnd < 0 ? command.length : lineEnd;
30 let line = command.slice(cursor, end).replace(/\r$/, '');
31 if (stripTabs) line = line.replace(/^\t+/, '');
32 cursor = lineEnd < 0 ? command.length : lineEnd + 1;
33 if (line === delimiter) break;
34 }
35 }
36 return cursor;
37}
38
39function escapedNewline(command: string, index: number): boolean {
40 let backslashes = 0;
41 for (let cursor = index - 1; command[cursor] === '\\'; cursor -= 1) backslashes += 1;
42 return backslashes % 2 === 1;
43}
44
45function commentStart(command: string, index: number): boolean {
46 if (command[index] !== '#') return false;
47 const previous = command[index - 1];
48 return previous === undefined || /[\s;&|(]/.test(previous);
49}
50
51export function withoutHereDocBodies(command: string): string {
52 let quote = '';
53 let hereDocs: HereDoc[] = [];
54 let result = '';
55 let copiedThrough = 0;
56
57 for (let index = 0; index < command.length; index += 1) {
58 const character = command[index];
59 if (quote) {
60 if (character === '\\') index += 1;
61 else if (character === quote) quote = '';
62 continue;
63 }
64 if (character === '\n' && hereDocs.length > 0 && !escapedNewline(command, index)) {
65 result += command.slice(copiedThrough, index + 1);
66 index = skipHereDocBodies(command, index + 1, hereDocs) - 1;
67 copiedThrough = index + 1;
68 hereDocs = [];
69 continue;
70 }
71 if (commentStart(command, index)) {
72 const lineEnd = command.indexOf('\n', index);
73 if (lineEnd < 0) break;
74 index = lineEnd - 1;
75 continue;
76 }
77 if (character === "'" || character === '"') {
78 quote = character;
79 continue;
80 }
81 if (character === '<' && command[index - 1] !== '<' && command[index + 1] === '<') {
82 const parsed = hereDocAt(command, index);
83 if (parsed) {
84 hereDocs.push(parsed.hereDoc);
85 index = parsed.end - 1;
86 }
87 }
88 }
89 return result + command.slice(copiedThrough);
90}
91src/lib/exec-names.ts 222 lines1export const EFFORTS = Object.freeze(['low', 'medium', 'high', 'xhigh', 'max'] as const);
2export type Effort = (typeof EFFORTS)[number];
3
4export const CLAUDE_PREFIX = 'sidequest-exec-';
5export const DISPATCH_PREFIX = 'sidequest-exec-dispatch-';
6export const READ_ONLY_CLAUDE_PREFIX = 'sidequest-exec-readonly-';
7export const READ_ONLY_DISPATCH_PREFIX = 'sidequest-exec-dispatch-readonly-';
8export const DISCOVERED_MODEL_PREFIX = 'sidequest-exec-model-';
9export const READ_ONLY_DISCOVERED_MODEL_PREFIX = 'sidequest-exec-readonly-model-';
10export const TICKET_PREFIX = 'sidequest-sq-';
11export const LEGACY_TICKET_PREFIX = 'sidequest-ticket-';
12export const DIAGNOSTIC_PROBE_NAME = 'sidequest-diagnostic-probe';
13
14export type ExecutorKind = 'codex_dispatch' | 'claude_builtin' | 'discovered_model' | 'read_only_codex_dispatch' | 'read_only_claude_builtin' | 'read_only_discovered_model' | 'ticket' | 'legacy_ticket' | 'unknown';
15export interface ExecutorClassification {
16 kind: ExecutorKind;
17 effort: Effort | null;
18}
19
20export function isEffort(value: unknown): value is Effort {
21 return typeof value === 'string' && (EFFORTS as readonly string[]).includes(value);
22}
23
24// Claude Code validates the Agent `name` parameter against
25// /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/, so 64 is a hard ceiling. The launch names
26// below stay far shorter: the native agent list truncates, and a name only earns
27// its place there by being readable at a glance.
28export const AGENT_NAME_MAX_LENGTH = 64;
29const LAUNCH_SLUG_MAX_WORDS = 3;
30const LAUNCH_SLUG_MAX_LENGTH = 24;
31const ROUTE_MODEL_TOKEN_MAX_LENGTH = 24;
32
33// Filler words spend the slug budget without distinguishing one ticket from
34// another, so they are dropped before the budget is counted.
35const LAUNCH_SLUG_FILLER = new Set([
36 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'but', 'by', 'for', 'from', 'in', 'into', 'is', 'it',
37 'its', 'of', 'on', 'or', 'over', 'per', 'that', 'the', 'their', 'then', 'this', 'to', 'under',
38 'via', 'when', 'while', 'with', 'without',
39]);
40
41function slugTokens(value: unknown): string[] {
42 return String(value == null ? '' : value)
43 .normalize('NFKD')
44 .toLowerCase()
45 .replace(/[^a-z0-9]+/g, ' ')
46 .trim()
47 .split(' ')
48 .filter(Boolean);
49}
50
51export function refSlug(ref: unknown): string {
52 return slugTokens(ref).join('-');
53}
54
55export function titleSlug(title: unknown): string {
56 const tokens = slugTokens(title);
57 const meaningful = tokens.filter((token) => !LAUNCH_SLUG_FILLER.has(token));
58 const chosen = (meaningful.length ? meaningful : tokens).slice(0, LAUNCH_SLUG_MAX_WORDS);
59 let slug = '';
60 for (const token of chosen) {
61 const next = slug ? `${slug}-${token}` : token;
62 if (next.length > LAUNCH_SLUG_MAX_LENGTH) break;
63 slug = next;
64 }
65 if (!slug && chosen.length) slug = String(chosen[0]).slice(0, LAUNCH_SLUG_MAX_LENGTH);
66 return slug;
67}
68
69function routeModelToken(resolvedExec: unknown): string {
70 if (!resolvedExec || typeof resolvedExec !== 'object') return '';
71 const exec = resolvedExec as Record<string, unknown>;
72 const isClaude = exec.backend === 'claude';
73 const value = isClaude
74 ? String(exec.runsModel || exec.dispatchModel || '')
75 : String(exec.runsLabel || exec.dispatchModel || exec.runsModel || '');
76 const tokens = slugTokens(value).filter((token) => !isClaude || token !== 'claude');
77 const token = isClaude ? tokens.join('-') : tokens.at(-1) || '';
78 return token.slice(0, ROUTE_MODEL_TOKEN_MAX_LENGTH);
79}
80
81/**
82 * The name a Sidequest launch carries in Claude Code's native agent list.
83 * Deterministic from board state alone: ticket ref, title, resolved execution
84 * route, and dispatch sequence. Sequence 1 is unsuffixed; a redispatch counts up
85 * (`sq-843-release-engine-terra-high-2`) so it never shadows a live sibling.
86 */
87export function dispatchLaunchName(ref: unknown, title?: unknown, resolvedExec?: unknown, effort?: unknown, sequence?: unknown): string {
88 const base = refSlug(ref) || 'sidequest';
89 const model = routeModelToken(resolvedExec);
90 const routeEffort = isEffort(effort) ? effort : '';
91 const route = [model, routeEffort].filter(Boolean);
92 const seq = Number(sequence);
93 const suffix = Number.isInteger(seq) && seq > 1 ? `-${seq}` : '';
94 const fixedName = [base, ...route].join('-') + suffix;
95 const availableTitleLength = AGENT_NAME_MAX_LENGTH - fixedName.length - 1;
96 const slug = titleSlug(title).slice(0, Math.max(availableTitleLength, 0)).replace(/-+$/, '');
97 return slug ? [base, slug, ...route].join('-') + suffix : fixedName;
98}
99
100// Codex dispatch executors are effort-collapsed: the gateway resolves BOTH model and
101// effort from the [sidequest-route model=... effort=...] marker in the briefing and
102// overwrites the request's output_config.effort, so a per-effort definition ladder on
103// this side carried five copies of dead frontmatter (verified on the wire 2026-08-02:
104// a def pinned to effort low produced effort=xhigh when the marker said so). Claude
105// executors keep the per-effort ladder because the Agent tool has no effort parameter,
106// leaving frontmatter as the only carrier.
107export const DISPATCH_NAME = 'sidequest-exec-dispatch';
108export const READ_ONLY_DISPATCH_NAME = 'sidequest-exec-dispatch-readonly';
109
110export function stableClaudeName(effort: Effort): string {
111 return `${CLAUDE_PREFIX}${effort}`;
112}
113
114export function stableDispatchName(_effort?: Effort): string {
115 return DISPATCH_NAME;
116}
117
118export function stableReadOnlyClaudeName(effort: Effort): string {
119 return `${READ_ONLY_CLAUDE_PREFIX}${effort}`;
120}
121
122export function stableReadOnlyDispatchName(_effort?: Effort): string {
123 return READ_ONLY_DISPATCH_NAME;
124}
125
126// A discovered model the gateway shim does not serve runs from a user-scope definition whose frontmatter
127// pins its full id, because the Agent tool's `model` parameter only accepts the four Claude aliases
128// (GH-361). Effort rides the same frontmatter, so that ladder is per model and per effort.
129export function discoveredModelExecutorName(agentSlug: string, effort: Effort): string {
130 return `${DISCOVERED_MODEL_PREFIX}${agentSlug}-${effort}`;
131}
132
133export function readOnlyDiscoveredModelExecutorName(agentSlug: string, effort: Effort): string {
134 return `${READ_ONLY_DISCOVERED_MODEL_PREFIX}${agentSlug}-${effort}`;
135}
136
137const DISCOVERED_MODEL_SUFFIX_RE = /^[a-z0-9][a-z0-9-]*-(low|medium|high|xhigh|max)$/;
138
139function discoveredModelEffort(name: string, prefix: string): Effort | null {
140 const effort = DISCOVERED_MODEL_SUFFIX_RE.exec(name.slice(prefix.length))?.[1];
141 return name.startsWith(prefix) && isEffort(effort) ? effort : null;
142}
143
144function classifyDiscoveredModel(name: string): ExecutorClassification | null {
145 const readOnlyEffort = discoveredModelEffort(name, READ_ONLY_DISCOVERED_MODEL_PREFIX);
146 if (readOnlyEffort) return { kind: 'read_only_discovered_model', effort: readOnlyEffort };
147 const effort = discoveredModelEffort(name, DISCOVERED_MODEL_PREFIX);
148 return effort ? { kind: 'discovered_model', effort } : null;
149}
150
151const BUNDLED_AGENT_NAMES = new Set([
152 DISPATCH_NAME,
153 READ_ONLY_DISPATCH_NAME,
154 DIAGNOSTIC_PROBE_NAME,
155 ...EFFORTS.map(stableClaudeName),
156 ...EFFORTS.map(stableReadOnlyClaudeName),
157]);
158const PLUGIN_NAMESPACE = 'sidequest:';
159
160export function canonicalExecutorName(name: string): string {
161 if (!name.startsWith(PLUGIN_NAMESPACE)) return name;
162 const unqualifiedName = name.slice(PLUGIN_NAMESPACE.length);
163 return BUNDLED_AGENT_NAMES.has(unqualifiedName) ? unqualifiedName : name;
164}
165
166export function bundledAgentType(name: string): string {
167 const canonicalName = canonicalExecutorName(name);
168 return BUNDLED_AGENT_NAMES.has(canonicalName) ? `${PLUGIN_NAMESPACE}${canonicalName}` : name;
169}
170
171export function isReadOnlyExecutor(name: unknown): boolean {
172 const kind = classify(name).kind;
173 return kind === 'read_only_codex_dispatch' || kind === 'read_only_claude_builtin' || kind === 'read_only_discovered_model';
174}
175
176export function isDiscoveredModelExecutor(name: unknown): boolean {
177 const kind = classify(name).kind;
178 return kind === 'discovered_model' || kind === 'read_only_discovered_model';
179}
180
181export function classify(value: unknown): ExecutorClassification {
182 if (typeof value !== 'string' || !value) return { kind: 'unknown', effort: null };
183 const name = canonicalExecutorName(value);
184
185 // The collapsed names carry no effort; the dispatch marker does. Checked before the
186 // prefix rules because READ_ONLY_DISPATCH_NAME is a proper prefix-collision with
187 // DISPATCH_PREFIX ('...dispatch-readonly' starts with '...dispatch-').
188 if (name === READ_ONLY_DISPATCH_NAME) return { kind: 'read_only_codex_dispatch', effort: null };
189 if (name === DISPATCH_NAME) return { kind: 'codex_dispatch', effort: null };
190 if (name === DIAGNOSTIC_PROBE_NAME) return { kind: 'unknown', effort: null };
191
192 // Both discovered-model prefixes extend a Claude ladder prefix, so they are matched first.
193 const discoveredModel = classifyDiscoveredModel(name);
194 if (discoveredModel) return discoveredModel;
195
196 // Pre-collapse records still name per-effort executors; classifying them keeps old
197 // dispatch records readable so the board can heal by redispatch instead of erroring.
198 if (name.startsWith(READ_ONLY_DISPATCH_PREFIX)) {
199 const effort = name.slice(READ_ONLY_DISPATCH_PREFIX.length);
200 if (isEffort(effort)) return { kind: 'read_only_codex_dispatch', effort };
201 return { kind: 'ticket', effort: null };
202 }
203 if (name.startsWith(READ_ONLY_CLAUDE_PREFIX)) {
204 const effort = name.slice(READ_ONLY_CLAUDE_PREFIX.length);
205 if (isEffort(effort)) return { kind: 'read_only_claude_builtin', effort };
206 return { kind: 'ticket', effort: null };
207 }
208 if (name.startsWith(DISPATCH_PREFIX)) {
209 const effort = name.slice(DISPATCH_PREFIX.length);
210 if (isEffort(effort)) return { kind: 'codex_dispatch', effort };
211 return { kind: 'ticket', effort: null };
212 }
213 if (name.startsWith(CLAUDE_PREFIX)) {
214 const effort = name.slice(CLAUDE_PREFIX.length);
215 if (isEffort(effort)) return { kind: 'claude_builtin', effort };
216 return { kind: 'ticket', effort: null };
217 }
218 if (name.startsWith(TICKET_PREFIX)) return { kind: 'ticket', effort: null };
219 if (name.startsWith(LEGACY_TICKET_PREFIX)) return { kind: 'legacy_ticket', effort: null };
220 return { kind: 'unknown', effort: null };
221}
222src/hooks/shared/main-thread-mutations.ts 45 lines1// The board fields only the main thread may set. This imports nothing, so the guards hooks module refuses
2// them from a subagent before any engine call that could fail, and the admission decision reads the same table.
3const CLOSEOUT_UPDATE_FIELDS = new Set([
4 'files', 'addFiles', 'removeFiles', 'status', 'readonly', 'readonlyOverride', 'workingTreeDelivery',
5 'externalDeliverable', 'verify', 'verifyKind', 'attestationArtifact', 'verifyCwd', 'admitComposition',
6 'executorVerify', 'executorVerifyKind', 'executorAttestationArtifact', 'executorVerifyCwd',
7]);
8
9export const MAIN_THREAD_MUTATIONS: Record<string, { matches: (input: Record<string, unknown>) => boolean; denial: string }> = {
10 mcp__plugin_sidequest_board__dispatch: {
11 matches: (input) => Object.hasOwn(input, 'workProvenance'),
12 denial: 'sidequest: subagents cannot declare work provenance. Ask the main thread to freeze source-bound factual provenance before dispatch; actor labels and shared session ids grant nothing.',
13 },
14 mcp__plugin_sidequest_board__update: {
15 matches: (input) => Array.from(CLOSEOUT_UPDATE_FIELDS).some((field) => Object.hasOwn(input, field)),
16 denial: 'sidequest: subagents cannot update closeout fields or admit a composition through MCP. Use scopeRequest for files, or ask the orchestrator to set closeout fields or use update.admitComposition from the main thread.',
17 },
18 mcp__plugin_sidequest_board__scopeRequest: {
19 // scopeRequest with grant:true widens declaredFiles for a refusal the ticket
20 // already recorded, which is a live-claim mutation just like the update fields
21 // above. Without this, a subagent could pass by:'orchestrator' to mint the
22 // grant itself; the store's by-mismatch check only catches the claim holder's
23 // own by, not an impersonated one.
24 matches: (input) => input.grant === true,
25 denial: 'sidequest: subagents cannot grant a refused scope request through MCP. Ask the orchestrator to grant it from the main thread.',
26 },
27 mcp__plugin_sidequest_board__remove: {
28 // force:true is the only path that deletes a live-claimed ticket, so it is the
29 // executor's escape hatch (delete the ticket to shed the claim). The store
30 // refuses ungranted live-claim deletion, but deny it here too so a subagent
31 // can never mint the main-thread grant by riding the MCP remove handler.
32 matches: (input) => input.force === true,
33 denial: 'sidequest: subagents cannot force-remove a ticket. Release your claim, or ask the orchestrator to remove it from the main thread.',
34 },
35 mcp__plugin_sidequest_board__verdict: {
36 matches: (input) => ['correct', 'finalize'].some(field => Object.hasOwn(input, field)),
37 denial: 'sidequest: subagents cannot finalize or correct completed review verdicts. Ask the orchestrator to use verdict with finalize or correct from the main thread.',
38 },
39};
40
41export function mainThreadMutationDenial(toolName: string, toolInput: Record<string, unknown>): string | null {
42 const rule = MAIN_THREAD_MUTATIONS[toolName];
43 return rule?.matches(toolInput) ? rule.denial : null;
44}
45src/hooks/shared/shell-writes.ts 107 lines1// The lexical half of the read-only shell guard. It imports nothing, so the guards hooks module can
2// tell a read-only executor's plain read from a command that names a write target without a process.
3type ShellToken = { operator: string } | { word: string };
4export type ShellSegment = { words: string[]; redirects: string[] };
5type WriteTargetReader = (words: string[]) => string[];
6
7const SHELL_TOKEN_RE = /(@"\r?\n[\s\S]*?\r?\n"@|@'\r?\n[\s\S]*?\r?\n'@|"(?:[^"\\`]|[\\`][\s\S]|"")*"|'(?:[^']|'')*')|((?:\d|&)?>>?(?:&\d?)?|&&|\|\||[|;\n])|([^\s|;&<>"']+)/g;
8const SEGMENT_OPERATORS = new Set(['&&', '||', '|', ';', '\n']);
9const NULL_SINKS = new Set(['/dev/null', 'nul', '$null']);
10const MUTATING_GIT = new Set([
11 'add', 'am', 'apply', 'checkout', 'cherry-pick', 'clean', 'commit', 'merge', 'mv', 'pull', 'push',
12 'rebase', 'reset', 'restore', 'revert', 'rm', 'stash', 'switch',
13]);
14
15function operands(words: string[]): string[] {
16 return words.slice(1).filter((word) => !word.startsWith('-'));
17}
18
19function gitWriteTargets(words: string[]): string[] {
20 const flagIndex = words.indexOf('-C');
21 const directory = flagIndex > 0 ? words[flagIndex + 1] : '.';
22 const subcommand = operands(words).find((word) => word !== directory);
23 return MUTATING_GIT.has(String(subcommand)) ? [directory || '.'] : [];
24}
25
26const SED_SCRIPT_FLAGS = new Set(['-e', '--expression', '-f', '--file']);
27
28// sed -i writes only its file operands: the script after -e/-f is data, and without -e the first
29// operand is the script (GH-566).
30function inPlaceEditTargets(words: string[]): string[] {
31 if (!words.some((word) => /^-[a-z]*i/i.test(word))) return [];
32 const fileOperands = words.slice(1).filter((word, index, rest) => word && !word.startsWith('-') && !SED_SCRIPT_FLAGS.has(rest[index - 1] ?? ''));
33 return words.some((word) => SED_SCRIPT_FLAGS.has(word)) ? fileOperands : fileOperands.slice(1);
34}
35
36// A content cmdlet's other words (-Value text, -ItemType File, -Encoding utf8) are data, not paths:
37// reading them as targets refused fixture writes to the evidence root (SQ-3202).
38function contentCmdletTargets(words: string[]): string[] {
39 const pathFlag = words.findIndex((word) => /^-(path|literalpath|filepath)$/i.test(word));
40 if (pathFlag > 0) return words.slice(pathFlag + 1, pathFlag + 2);
41 return words.slice(1).filter((word, index) => (
42 !word.startsWith('-') && !/^-(value|encoding|itemtype|width|name)$/i.test(words[index]!)
43 )).slice(0, 1);
44}
45
46const WRITE_TARGET_READERS = new Map<string, WriteTargetReader>([
47 ...['rm', 'rmdir', 'mv', 'touch', 'mkdir', 'tee', 'truncate', 'chmod', 'chown', 'unlink',
48 'remove-item', 'move-item', 'rename-item', 'clear-content',
49 'ri', 'del', 'erase', 'rd', 'md', 'move', 'ren'].map((name): [string, WriteTargetReader] => [name, operands]),
50 ...['set-content', 'add-content', 'out-file', 'new-item', 'ni'].map((name): [string, WriteTargetReader] => [name, contentCmdletTargets]),
51 ...['cp', 'copy', 'copy-item', 'ln', 'install', 'rsync'].map((name): [string, WriteTargetReader] => [name, (words) => operands(words).slice(-1)]),
52 ['sed', inPlaceEditTargets],
53 ['perl', inPlaceEditTargets],
54 ['git', gitWriteTargets],
55]);
56
57function commandName(words: string[]): string {
58 return (words[0] || '').split(/[\\/]/).pop()!.toLowerCase().replace(/\.exe$/, '');
59}
60
61export function isGitMutation(words: string[]): boolean {
62 return commandName(words) === 'git' && gitWriteTargets(words).length > 0;
63}
64
65export function commandWriteTargets(words: string[]): string[] {
66 const reader = WRITE_TARGET_READERS.get(commandName(words));
67 return reader ? reader(words) : [];
68}
69
70function shellTokens(command: string): ShellToken[] {
71 return [...command.matchAll(SHELL_TOKEN_RE)].map((match) => (
72 match[2] ? { operator: match[2] } : { word: (match[1] || match[3]!).replace(/^(["'])([\s\S]*)\1$/, '$2') }
73 ));
74}
75
76// A dup such as `2>&1` names a descriptor, not a file, so only a plain redirect captures the next word.
77export function shellSegments(command: string): ShellSegment[] {
78 const segments: ShellSegment[] = [{ words: [], redirects: [] }];
79 let pendingRedirect = false;
80 for (const token of shellTokens(command)) {
81 const segment = segments[segments.length - 1]!;
82 if ('word' in token) {
83 (pendingRedirect ? segment.redirects : segment.words).push(token.word);
84 pendingRedirect = false;
85 } else if (SEGMENT_OPERATORS.has(token.operator)) {
86 segments.push({ words: [], redirects: [] });
87 } else {
88 pendingRedirect = !token.operator.includes('&', 1);
89 }
90 }
91 return segments;
92}
93
94export function ignoredTarget(word: string): boolean {
95 return !word || word.startsWith('$') || NULL_SINKS.has(word.toLowerCase());
96}
97
98function segmentNamesWrite(segment: ShellSegment): boolean {
99 return isGitMutation(segment.words)
100 || [...segment.redirects, ...commandWriteTargets(segment.words)].some((word) => !ignoredTarget(word));
101}
102
103// True when readOnlyShellRefusal could refuse the command, whatever the cwd.
104export function namesShellWrite(command: string): boolean {
105 return shellSegments(command).some(segmentNamesWrite);
106}
107