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