Skip to content

Commit 6e60fd8

Browse files
fix(graph-hooks): add branch-change detection and fix shell injection vulnerability
- Add createGraphToolBeforeHook to capture pre-command git branch state - Enhance createGraphToolAfterHook to detect branch changes and file restorations - Add isBranchChangeCommand to identify git branch-changing commands - Add extractCheckoutPaths to extract file paths from git checkout restores - Fix shell injection vulnerability by using execFileSync instead of execSync - Add comprehensive test coverage for branch-change detection (18 new tests) - Improve path containment checks to avoid prefix collisions - Wire graph before-hook into plugin initialization before sandbox/tool hooks
1 parent 4c2bfd7 commit 6e60fd8

3 files changed

Lines changed: 972 additions & 23 deletions

File tree

‎src/hooks/graph-tools.ts‎

Lines changed: 269 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,163 @@
11
import type { Hooks } from '@opencode-ai/plugin'
22
import type { Logger } from '../types'
33
import type { GraphService } from '../graph/service'
4+
import { join, isAbsolute, normalize, resolve, sep } from 'path'
5+
import { execFileSync, execSync } from 'child_process'
46

57
interface GraphToolHookDeps {
68
graphService: GraphService | null
79
logger: Logger
810
cwd: string
911
}
1012

13+
/**
14+
* Map storing pre-command branch snapshots keyed by callID.
15+
* Only populated for bash commands that are branch-change candidates.
16+
* Exported for testing purposes.
17+
*/
18+
export const pendingBranchSnapshots = new Map<string, { cwd: string; branch: string | null }>()
19+
20+
/**
21+
* Resolves the effective git working directory for a bash tool call.
22+
* - Uses args.workdir when present
23+
* - Falls back to the plugin/project cwd
24+
* - Normalizes relative paths against the project root
25+
*/
26+
export function resolveBashWorkdir(args: unknown, projectCwd: string): string {
27+
const workdirArg = (args as Record<string, unknown>)?.workdir as string | undefined
28+
29+
if (workdirArg) {
30+
// Normalize and resolve workdir against project root if relative
31+
const normalized = normalize(workdirArg)
32+
return isAbsolute(normalized) ? normalized : join(projectCwd, normalized)
33+
}
34+
35+
return projectCwd
36+
}
37+
38+
/**
39+
* Determines whether a bash command is worth branch tracking.
40+
* Initial command set includes git branch-changing commands.
41+
* Excludes file restoration commands with explicit -- separator like `git checkout -- <path>`.
42+
* For bare `git checkout <arg>` without --, we conservatively track it and let the
43+
* after-hook compare pre/post branch state to determine if a rescan is needed.
44+
*/
45+
export function isBranchChangeCommand(args: unknown): boolean {
46+
const command = ((args as Record<string, unknown>)?.command as string) || ''
47+
48+
if (!command.trim()) {
49+
return false
50+
}
51+
52+
// Normalize command by trimming whitespace
53+
const trimmed = command.trim()
54+
55+
// git switch always changes branch
56+
if (/^git\s+switch\s+/.test(trimmed)) {
57+
return true
58+
}
59+
60+
// git worktree add changes the worktree context
61+
if (/^git\s+worktree\s+add\s+/.test(trimmed)) {
62+
return true
63+
}
64+
65+
// git checkout can switch branches OR restore files
66+
// Branch checkout: git checkout <branch> (no -- separator)
67+
// File restore: git checkout -- <path> or git checkout <rev> -- <path>
68+
// We conservatively track bare `git checkout <arg>` (without --) because
69+
// we can't reliably distinguish branches from file paths without running git,
70+
// and we don't have the working directory context here.
71+
// The after-hook will compare pre/post branch state to determine the actual behavior.
72+
if (/^git\s+checkout\s+(.+)$/.test(trimmed)) {
73+
const checkoutArgs = trimmed.match(/^git\s+checkout\s+(.+)$/)?.[1] || ''
74+
// If it has -- separator, it's definitely file restoration
75+
if (checkoutArgs.includes('--')) {
76+
return false
77+
}
78+
// Bare checkout without --: conservatively track it
79+
// The after-hook will determine if branch actually changed
80+
return true
81+
}
82+
83+
return false
84+
}
85+
86+
/**
87+
* Extract file paths from git checkout file restoration commands.
88+
* Handles patterns like:
89+
* - git checkout -- <path>
90+
* - git checkout HEAD -- <path>
91+
* - git checkout <commit> -- <path>
92+
* - git checkout <path> (unambiguous file path without --)
93+
* Returns empty array if not a checkout file restore command.
94+
*/
95+
export function extractCheckoutPaths(args: unknown, workdir: string): string[] {
96+
const command = ((args as Record<string, unknown>)?.command as string) || ''
97+
98+
if (!command.trim()) {
99+
return []
100+
}
101+
102+
const trimmed = command.trim()
103+
104+
// Match git checkout with -- separator (file restoration)
105+
// Patterns: git checkout -- <path>, git checkout HEAD -- <path>, git checkout <commit> -- <path>
106+
const restoreMatch = trimmed.match(/^git\s+checkout\s+(?:[^\s-]+\s+)?--\s+(.+)$/)
107+
if (restoreMatch) {
108+
const pathsStr = restoreMatch[1]
109+
// Split by whitespace to handle multiple paths
110+
return pathsStr.split(/\s+/).filter(p => p.length > 0)
111+
}
112+
113+
// Match git checkout <path> without -- separator
114+
// This is ambiguous: could be a branch or a file path
115+
// Use git to reliably check if the argument is a valid branch/ref
116+
const checkoutMatch = trimmed.match(/^git\s+checkout\s+(.+)$/)
117+
if (checkoutMatch) {
118+
const checkoutArgs = checkoutMatch[1]
119+
// Use git rev-parse to check if it's a valid ref/branch
120+
// If rev-parse succeeds, it's a branch/ref, not a file path
121+
try {
122+
execFileSync('git', ['rev-parse', '--verify', checkoutArgs, '--'], {
123+
encoding: 'utf-8',
124+
stdio: ['pipe', 'pipe', 'pipe'],
125+
cwd: workdir,
126+
})
127+
// It's a valid ref/branch, not a file path
128+
return []
129+
} catch {
130+
// rev-parse failed, so it's likely a file path -> extract it
131+
return [checkoutArgs]
132+
}
133+
}
134+
135+
return []
136+
}
137+
138+
/**
139+
* Reads the current branch name from the resolved working directory using git.
140+
* Returns null when the directory is not a repo or the branch cannot be determined.
141+
*/
142+
export function getCurrentBranch(workdir: string): string | null {
143+
try {
144+
const result = execSync('git rev-parse --abbrev-ref HEAD', {
145+
cwd: workdir,
146+
encoding: 'utf-8',
147+
stdio: ['pipe', 'pipe', 'pipe'],
148+
})
149+
return result.trim()
150+
} catch {
151+
// Not a git repo, or git command failed
152+
return null
153+
}
154+
}
155+
11156
/**
12157
* Extract file paths from tool outputs that may have mutated files.
13158
* This handles common file-editing tools and bash commands that clearly mutate tracked files.
14159
*/
15-
function extractMutatedPaths(tool: string, output: string, args?: unknown): string[] {
160+
function extractMutatedPaths(tool: string, output: string, args: unknown): string[] {
16161
const paths: string[] = []
17162

18163
// Handle apply_patch tool - explicitly edits files
@@ -119,31 +264,146 @@ function extractMutatedPaths(tool: string, output: string, args?: unknown): stri
119264
}
120265

121266
/**
122-
* Check if a path is within the project root
267+
* Check if a path is within the project root using proper path containment.
268+
* Uses realpath-like normalization to avoid prefix collisions.
269+
* Example: /Users/chris/development/opencode-forge should not contain /Users/chris/development/opencode-forge-backup
123270
*/
124271
function isPathInProject(absPath: string, cwd: string): boolean {
125-
return absPath.startsWith(cwd)
272+
const normalizedPath = resolve(absPath)
273+
const normalizedCwd = resolve(cwd)
274+
// Ensure cwd ends with separator for proper containment check (cross-platform)
275+
const cwdWithSep = normalizedCwd.endsWith(sep)
276+
? normalizedCwd
277+
: normalizedCwd + sep
278+
return normalizedPath === normalizedCwd || normalizedPath.startsWith(cwdWithSep)
126279
}
127280

128-
export function createGraphToolAfterHook(deps: GraphToolHookDeps): Hooks['tool.execute.after'] {
281+
/**
282+
* Check if a workdir is within the project root using proper path containment.
283+
*/
284+
function isWorkdirInProject(workdir: string, projectCwd: string): boolean {
285+
return isPathInProject(workdir, projectCwd)
286+
}
287+
288+
/**
289+
* Creates a before-hook for graph tool execution that captures pre-command branch state.
290+
* Only inspects bash tool calls that are branch-change candidates.
291+
*/
292+
export function createGraphToolBeforeHook(deps: GraphToolHookDeps): Hooks['tool.execute.before'] {
129293
return async (
130-
input: { tool: string; sessionID: string; callID: string; args?: unknown },
131-
output: { output?: string },
294+
input: { tool: string; sessionID: string; callID: string },
295+
output: { args: unknown },
132296
) => {
133297
// No-op if graph service is disabled
134298
if (!deps.graphService) {
135299
return
136300
}
301+
302+
// Only inspect bash tool calls
303+
if (input.tool !== 'bash') {
304+
return
305+
}
306+
307+
// Check if this is a branch-change candidate command
308+
if (!isBranchChangeCommand(output.args)) {
309+
return
310+
}
311+
312+
// Resolve the effective working directory for this bash call
313+
const workdir = resolveBashWorkdir(output.args, deps.cwd)
314+
315+
// Only track branch changes within the project worktree
316+
// Skip if workdir is outside the project root (uses proper path containment)
317+
if (!isWorkdirInProject(workdir, deps.cwd)) {
318+
deps.logger.debug(`Graph hook: skipping branch tracking for workdir outside project: ${workdir}`)
319+
return
320+
}
321+
322+
// Capture the pre-command branch name (may be null if not in a repo)
323+
const branch = getCurrentBranch(workdir)
324+
325+
// Store the snapshot keyed by callID
326+
pendingBranchSnapshots.set(input.callID, {
327+
cwd: workdir,
328+
branch,
329+
})
330+
331+
deps.logger.debug(`Graph hook: captured pre-command branch snapshot for ${input.callID}: branch=${branch ?? 'null'}, cwd=${workdir}`)
332+
}
333+
}
137334

138-
const mutatedPaths = extractMutatedPaths(input.tool, output.output ?? '', input.args)
335+
export function createGraphToolAfterHook(deps: GraphToolHookDeps): Hooks['tool.execute.after'] {
336+
return async (
337+
input: { tool: string; sessionID: string; callID: string; args: unknown },
338+
output: { title: string; output: string; metadata: unknown },
339+
) => {
340+
// No-op if graph service is disabled
341+
if (!deps.graphService) {
342+
return
343+
}
344+
345+
// Check for pending branch snapshot first
346+
const snapshot = pendingBranchSnapshots.get(input.callID)
347+
348+
// Always clear the pending snapshot entry for the callID
349+
pendingBranchSnapshots.delete(input.callID)
350+
351+
if (snapshot) {
352+
// This was a branch-change candidate - check if branch actually changed
353+
const nextBranch = getCurrentBranch(snapshot.cwd)
354+
355+
if (snapshot.branch !== nextBranch) {
356+
// Branch changed - trigger full rescan
357+
deps.logger.log(`Graph hook: branch switch detected (${snapshot.branch ?? 'null'} -> ${nextBranch ?? 'null'}), triggering full graph rescan in background`)
358+
void deps.graphService.scan().catch((err) => {
359+
deps.logger.error('Graph hook: background graph rescan failed', err)
360+
})
361+
return
362+
}
363+
364+
// Branch did not change - check for file restoration commands first, then fall through to per-file mutation path
365+
deps.logger.debug(`Graph hook: no branch change for ${input.callID}, checking file mutations`)
366+
367+
// Extract file paths from git checkout file restoration commands
368+
const workdir = resolveBashWorkdir(input.args, deps.cwd)
369+
const checkoutPaths = extractCheckoutPaths(input.args, workdir)
370+
if (checkoutPaths.length > 0) {
371+
deps.logger.debug(`Graph hook: detected git checkout file restoration, enqueuing ${checkoutPaths.length} file(s)`)
372+
for (const path of checkoutPaths) {
373+
const absPath = path.startsWith('/') ? path : `${snapshot.cwd}/${path}`
374+
if (isPathInProject(absPath, deps.cwd)) {
375+
deps.graphService.onFileChanged(absPath)
376+
}
377+
}
378+
return
379+
}
380+
}
381+
382+
// Extract per-file mutations (existing behavior)
383+
const mutatedPaths: string[] = extractMutatedPaths(input.tool, output.output ?? '', input.args)
384+
385+
// Also check for git checkout file restoration commands (these don't have branch snapshots)
386+
const workdir = resolveBashWorkdir(input.args, deps.cwd)
387+
const checkoutPaths = extractCheckoutPaths(input.args, workdir)
388+
if (checkoutPaths.length > 0) {
389+
for (const path of checkoutPaths) {
390+
const absPath = path.startsWith('/') ? path : `${workdir}/${path}`
391+
if (isPathInProject(absPath, deps.cwd)) {
392+
mutatedPaths.push(absPath)
393+
}
394+
}
395+
}
139396

140397
if (mutatedPaths.length === 0) {
141398
return
142399
}
143400

401+
// Resolve the effective working directory for bash calls to correctly resolve relative paths
402+
const bashWorkdir = input.tool === 'bash' ? workdir : deps.cwd
403+
144404
for (const path of mutatedPaths) {
145-
// Resolve to absolute path
146-
const absPath = path.startsWith('/') ? path : `${deps.cwd}/${path}`
405+
// Checkout paths are already absolute, others need resolution
406+
const absPath = path.startsWith('/') ? path : resolve(bashWorkdir, path)
147407

148408
// Only enqueue if within project
149409
if (!isPathInProject(absPath, deps.cwd)) {

‎src/index.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Plugin, PluginInput, Hooks } from '@opencode-ai/plugin'
22
import { createOpencodeClient as createV2Client } from '@opencode-ai/sdk/v2'
33
import { agents } from './agents'
44
import { createConfigHandler } from './config'
5-
import { createSessionHooks, createLoopEventHandler, createGraphToolAfterHook } from './hooks'
5+
import { createSessionHooks, createLoopEventHandler } from './hooks'
66
import { initializeDatabase, resolveDataDir, closeDatabase } from './storage'
77
import { createKvService } from './services/kv'
88
import { createLoopService, migrateRalphKeys } from './services/loop'
@@ -16,6 +16,7 @@ import type { PluginConfig, CompactionConfig } from './types'
1616
import { createTools, createToolExecuteBeforeHook, createToolExecuteAfterHook, createPlanApprovalEventHook } from './tools'
1717
import { createSandboxToolBeforeHook, createSandboxToolAfterHook } from './hooks/sandbox-tools'
1818
import { createGraphCommandEventHook } from './hooks/graph-command'
19+
import { createGraphToolBeforeHook, createGraphToolAfterHook } from './hooks/graph-tools'
1920
import type { ToolContext } from './tools'
2021
import type { GraphService } from './graph'
2122
import { createGraphStatusCallback, writeGraphStatus, UNAVAILABLE_STATUS } from './utils/graph-status-store'
@@ -228,6 +229,11 @@ export function createForgePlugin(config: PluginConfig): Plugin {
228229
sandboxManager,
229230
logger,
230231
})
232+
const graphBeforeHook = createGraphToolBeforeHook({
233+
graphService: graphService || null,
234+
logger,
235+
cwd: directory,
236+
})
231237
const graphAfterHook = createGraphToolAfterHook({
232238
graphService: graphService || null,
233239
logger,
@@ -263,6 +269,9 @@ export function createForgePlugin(config: PluginConfig): Plugin {
263269
if (loopName) {
264270
logger.log(`[tool-before] ${input.tool} callID=${input.callID} session=${input.sessionID} loop=${loopName}`)
265271
}
272+
// Graph hook must run BEFORE sandbox hook to inspect original command
273+
// Graph hook must also run BEFORE toolExecuteBeforeHook to capture original args
274+
await graphBeforeHook!(input, output)
266275
await toolExecuteBeforeHook!(input, output)
267276
await sandboxBeforeHook!(input, output)
268277
},

0 commit comments

Comments
 (0)