SLOPSHOPPER

sidequest

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

newguardtoaststatuspromptprocess
★ 2v6.0.2MITupdated 2026-10-20Hanafubuki/ops-control/plugins/sidequest
A shopper browsing a rack in a slop shop
README

Sidequest

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

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.

Start with Claude

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.

Keep tasks through context compaction

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 board every day

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.

If something stops working

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.

Support

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.

License

MIT (c) Eigenwise

Source 13 files
hooks/guards/register.ts 213 lines
1import 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}
213
hooks/guards/context-guards.ts 174 lines
1import { 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}
174
hooks/guards/git-guards.ts 255 lines
1import 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}
255
hooks/guards/executor-admission.ts 178 lines
1import 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}
178
hooks/guards/snapshot.ts 114 lines
1import 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}
114
hooks/guards/text-guards.ts 193 lines
1import { 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}
193
src/lib/denied-tools.ts 27 lines
1// 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}
27
hooks/guards/git-command.ts 101 lines
1import { 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}
101
hooks/guards/heredocs.ts 91 lines
1export 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}
91
src/lib/exec-names.ts 222 lines
1export 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}
222
src/hooks/shared/main-thread-mutations.ts 45 lines
1// 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}
45
src/hooks/shared/shell-writes.ts 107 lines
1// 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