main
  1/**
  2 * OpenCode plugin for unified AI agent behavior
  3 *
  4 * Provides:
  5 * - validate-git-push: Block unsafe git push/add commands
  6 * - validate-write-path: Block writes to old locations, enforce filename format
  7 * - session logging: Log session start to unified AI storage
  8 * - terminal title: Update terminal title on tool use
  9 *
 10 * Shares behavior with Claude hooks (~/.config/claude/hooks/) and
 11 * pi extensions (~/.pi/agent/extensions/).
 12 */
 13
 14import type { Plugin } from "@opencode-ai/plugin"
 15import { readFileSync, appendFileSync, mkdirSync, existsSync } from "fs"
 16import { join, basename } from "path"
 17import { homedir } from "os"
 18import { execSync } from "child_process"
 19
 20// ── Shared utilities (matches lib.ts from Claude hooks) ──
 21
 22const AI_BASE = join(homedir(), ".local", "share", "ai")
 23
 24function todayStr(): string {
 25  const d = new Date()
 26  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
 27}
 28
 29function monthStr(): string {
 30  const d = new Date()
 31  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
 32}
 33
 34function slug(title: string): string {
 35  return title
 36    .toLowerCase()
 37    .replace(/[^a-z0-9]+/g, "-")
 38    .replace(/^-+|-+$/g, "")
 39    .substring(0, 60)
 40}
 41
 42function expandHome(p: string): string {
 43  return p.replace(/^~/, homedir())
 44}
 45
 46function getHostname(): string {
 47  try {
 48    return execSync("hostname", { encoding: "utf-8" }).trim()
 49  } catch {
 50    return "unknown"
 51  }
 52}
 53
 54// ── Path policies (shared with Claude and Pi) ──
 55
 56interface PathPolicy {
 57  name: string
 58  description: string
 59  filenamePattern?: string
 60  pathPattern?: string
 61  allowedPaths?: string[]
 62  blockedPaths?: string[]
 63  action: "block" | "warn"
 64  enabled: boolean
 65}
 66
 67function loadPathPolicies(): PathPolicy[] {
 68  try {
 69    const raw = readFileSync(
 70      expandHome("~/.config/ai/path-policies.json"),
 71      "utf-8"
 72    )
 73    const data = JSON.parse(raw)
 74    return [
 75      ...(data.filenameRules || []),
 76      ...(data.locationPolicies || []),
 77    ].filter((p: PathPolicy) => p.enabled)
 78  } catch {
 79    return []
 80  }
 81}
 82
 83function checkPathPolicies(
 84  filePath: string
 85): { blocked: boolean; reason?: string } {
 86  const policies = loadPathPolicies()
 87  const expanded = expandHome(filePath)
 88  const fname = basename(expanded)
 89
 90  for (const policy of policies) {
 91    // Check filename pattern
 92    if (policy.filenamePattern) {
 93      const re = new RegExp(policy.filenamePattern)
 94      if (!re.test(fname)) continue
 95    }
 96
 97    // Check path pattern
 98    if (policy.pathPattern) {
 99      const re = new RegExp(policy.pathPattern.replace(/\//g, "\\/"))
100      if (!re.test(expanded)) continue
101    }
102
103    // Check blocked paths
104    if (policy.blockedPaths) {
105      const inBlocked = policy.blockedPaths.some((bp) => {
106        const exp = expandHome(bp).replace(/\*/g, ".*")
107        return new RegExp(`^${exp}`).test(expanded)
108      })
109      if (inBlocked && policy.action === "block") {
110        return {
111          blocked: true,
112          reason: `[${policy.name}] ${policy.description}`,
113        }
114      }
115    }
116
117    // Check allowed paths (if specified, path must match one)
118    if (policy.allowedPaths && !policy.blockedPaths) {
119      const inAllowed = policy.allowedPaths.some((ap) => {
120        const exp = expandHome(ap).replace(/\*/g, ".*")
121        return new RegExp(`^${exp}`).test(expanded)
122      })
123      if (!inAllowed && policy.action === "block") {
124        return {
125          blocked: true,
126          reason: `[${policy.name}] ${policy.description}`,
127        }
128      }
129    }
130  }
131
132  return { blocked: false }
133}
134
135// ── Git validation (matches validate-git-push.ts) ──
136
137function isUnsafeGitCommand(command: string): string | null {
138  // Split on && || ; to check each subcommand
139  const commands = command.split(/\s*(?:&&|\|\||;)\s*/)
140
141  for (const cmd of commands) {
142    const trimmed = cmd.trim()
143
144    // git push without explicit refspec
145    if (/^git\s+push\b/.test(trimmed)) {
146      const parts = trimmed.split(/\s+/)
147      // git push = 2 parts (bare push)
148      // git push origin = 3 parts (no refspec)
149      // git push origin branch:branch = 4+ parts (safe)
150      // git push --flags origin = need to skip flags
151      const nonFlags = parts.filter((p) => !p.startsWith("-") && p !== "git" && p !== "push")
152      if (nonFlags.length < 2) {
153        return `Unsafe: \`${trimmed}\` — must specify refspec (e.g., git push origin branch:branch)`
154      }
155      // Check refspec has colon
156      const lastArg = parts[parts.length - 1]
157      if (!lastArg.includes(":") && !lastArg.startsWith("-")) {
158        return `Unsafe: \`${trimmed}\` — refspec must use branch:branch format`
159      }
160    }
161
162    // git add . or git add -A (too broad)
163    if (/^git\s+add\s+(\.|--all|-A)\s*$/.test(trimmed)) {
164      return `Unsafe: \`${trimmed}\` — use explicit file paths instead`
165    }
166  }
167
168  return null
169}
170
171// ── Plugin ──
172
173const plugin: Plugin = async ({ $ }) => {
174  let sessionInitialized = false
175
176  return {
177    /**
178     * Validate tool calls before execution
179     */
180    "tool.execute.before": async (input, output) => {
181      const toolName = (input.tool || "").toLowerCase()
182
183      // Validate git commands in bash/shell
184      if (toolName === "bash" || toolName === "shell") {
185        const command = output.args?.command || output.args?.input || ""
186        if (typeof command === "string") {
187          const reason = isUnsafeGitCommand(command)
188          if (reason) {
189            output.args = { ...output.args, command: `echo "BLOCKED: ${reason}"` }
190            return
191          }
192        }
193      }
194
195      // Validate write/edit paths
196      if (toolName === "write" || toolName === "edit") {
197        const filePath = output.args?.file_path || output.args?.path || output.args?.filePath || ""
198        if (typeof filePath === "string" && filePath) {
199          const check = checkPathPolicies(filePath)
200          if (check.blocked) {
201            if (toolName === "write") {
202              output.args = {
203                ...output.args,
204                command: `echo "BLOCKED: ${check.reason}"`,
205              }
206            }
207            // For edit, we can't easily block, but we log a warning
208            console.error(`[opencode-hooks] BLOCKED ${toolName}: ${check.reason}`)
209            return
210          }
211        }
212      }
213    },
214
215    /**
216     * Update terminal title after tool use
217     */
218    "tool.execute.after": async (input, output) => {
219      try {
220        // Simple terminal title update
221        const cwd = process.cwd()
222        const project = basename(cwd)
223        process.stdout.write(`\x1b]0;opencode: ${project}\x07`)
224      } catch {
225        // Silent failure
226      }
227    },
228
229    /**
230     * Session lifecycle events
231     */
232    event: async ({ event }) => {
233      if (event.type === "session.created" && !sessionInitialized) {
234        sessionInitialized = true
235
236        try {
237          // Log session start to unified AI storage
238          const sessionDir = join(AI_BASE, "sessions", monthStr())
239          mkdirSync(sessionDir, { recursive: true })
240
241          const logFile = join(sessionDir, `${todayStr()}_session-log.txt`)
242          const now = new Date().toISOString().replace("Z", "+00:00")
243          const host = getHostname()
244          const entry = `${now} - Session started (opencode) on ${host}\n`
245
246          appendFileSync(logFile, entry)
247
248          // Set terminal title
249          process.stdout.write(`\x1b]0;opencode: ${basename(process.cwd())}\x07`)
250        } catch (error) {
251          console.error("[opencode-hooks] initialize-session error:", error)
252        }
253      }
254    },
255  }
256}
257
258export default plugin